From 565e0133529d83e6710ed29983be03ca36207023 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:56:34 +0000 Subject: [PATCH 1/4] Initial plan From 910eedde16e5fd1142dbd1606c28c14722c1a150 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:21:11 +0000 Subject: [PATCH 2/4] Add collaboration types, validation, and factory functions to shared-types Adds V2 advanced rehearsal collaboration contracts: - CollaborationSession, RehearsalComment, PlayerAssignment, RoleApproval types - Validation functions with strict field checking - Factory helpers: createCollaborationSession, createRehearsalComment, createPlayerAssignment, createRoleApproval - Comprehensive test coverage for all new types and validators Agent-Logs-Url: https://github.com/Seongho-Bae/bandscope/sessions/1cfe246a-04a9-4a53-8861-1496bc130f1f Co-authored-by: seonghobae <8172694+seonghobae@users.noreply.github.com> --- packages/shared-types/src/index.ts | 283 +++++++++++++++++ packages/shared-types/test/index.test.ts | 384 ++++++++++++++++++++++- 2 files changed, 666 insertions(+), 1 deletion(-) diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index defab024..b257c15a 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -1553,3 +1553,286 @@ export function parseRehearsalWorkspace(value: unknown): RehearsalWorkspace { )); return parsed; } + +// ─── V2: Advanced Rehearsal Collaboration ─────────────────────────────────── + +export /** Documented. */ +const COMMENT_STATUSES = ["active", "resolved", "archived"] as const; +export /** Documented. */ +const APPROVAL_STATUSES = ["pending", "approved", "changes_requested"] as const; +export /** Documented. */ +const ASSIGNMENT_STATUSES = ["assigned", "in_progress", "done"] as const; +export /** Documented. */ +const COLLABORATION_SESSION_STATES = ["active", "paused", "closed"] as const; +export /** Documented. */ +const COMMENT_TARGET_KINDS = ["section", "role", "song"] as const; + +/** Documented. */ +export type CommentStatus = (typeof COMMENT_STATUSES)[number]; +/** Documented. */ +export type ApprovalStatus = (typeof APPROVAL_STATUSES)[number]; +/** Documented. */ +export type AssignmentStatus = (typeof ASSIGNMENT_STATUSES)[number]; +/** Documented. */ +export type CollaborationSessionState = (typeof COLLABORATION_SESSION_STATES)[number]; +/** Documented. */ +export type CommentTargetKind = (typeof COMMENT_TARGET_KINDS)[number]; + +/** Documented. */ +export type CollaborationParticipant = { + id: string; + displayName: string; + role: string; +}; + +/** Documented. */ +export type CommentTarget = { + kind: CommentTargetKind; + sectionId?: string; + roleId?: string; +}; + +/** Documented. */ +export type RehearsalComment = { + id: string; + authorId: string; + target: CommentTarget; + body: string; + status: CommentStatus; + createdAt: string; + updatedAt: string; + parentId?: string; +}; + +/** Documented. */ +export type RoleApproval = { + roleId: string; + sectionId: string; + status: ApprovalStatus; + reviewerId: string; + comment: string; + updatedAt: string; +}; + +/** Documented. */ +export type PlayerAssignment = { + id: string; + participantId: string; + roleId: string; + status: AssignmentStatus; + notes: string; + assignedAt: string; +}; + +/** Documented. */ +export type CollaborationSession = { + id: string; + workspaceId: string; + state: CollaborationSessionState; + participants: CollaborationParticipant[]; + comments: RehearsalComment[]; + approvals: RoleApproval[]; + assignments: PlayerAssignment[]; + createdAt: string; + updatedAt: string; +}; + +/** Documented. */ +function validateCollaborationParticipant(value: unknown, path: string): string | null { + if (!isRecord(value)) return invalidField(path); + const extraKey = unexpectedKey(value, ["id", "displayName", "role"], path); + if (extraKey) return extraKey; + if (typeof value.id !== "string" || value.id.trim().length === 0) return invalidField(`${path}.id`); + if (typeof value.displayName !== "string" || value.displayName.trim().length === 0) return invalidField(`${path}.displayName`); + if (typeof value.role !== "string") return invalidField(`${path}.role`); + return null; +} + +/** Documented. */ +function validateCommentTarget(value: unknown, path: string): string | null { + if (!isRecord(value)) return invalidField(path); + const extraKey = unexpectedKey(value, ["kind", "sectionId", "roleId"], path); + if (extraKey) return extraKey; + if (!isOneOf(COMMENT_TARGET_KINDS, value.kind)) return invalidField(`${path}.kind`); + if (value.sectionId !== undefined && typeof value.sectionId !== "string") return invalidField(`${path}.sectionId`); + if (value.roleId !== undefined && typeof value.roleId !== "string") return invalidField(`${path}.roleId`); + if (value.kind === "section" && typeof value.sectionId !== "string") return invalidField(`${path}.sectionId`); + if (value.kind === "role" && (typeof value.sectionId !== "string" || typeof value.roleId !== "string")) { + return invalidField(`${path}.roleId`); + } + return null; +} + +/** Documented. */ +function validateRehearsalComment(value: unknown, path: string): string | null { + if (!isRecord(value)) return invalidField(path); + const extraKey = unexpectedKey(value, ["id", "authorId", "target", "body", "status", "createdAt", "updatedAt", "parentId"], path); + if (extraKey) return extraKey; + if (typeof value.id !== "string" || value.id.trim().length === 0) return invalidField(`${path}.id`); + if (typeof value.authorId !== "string" || value.authorId.trim().length === 0) return invalidField(`${path}.authorId`); + const targetError = validateCommentTarget(value.target, `${path}.target`); + if (targetError) return targetError; + if (typeof value.body !== "string") return invalidField(`${path}.body`); + if (!isOneOf(COMMENT_STATUSES, value.status)) return invalidField(`${path}.status`); + if (typeof value.createdAt !== "string" || value.createdAt.trim().length === 0) return invalidField(`${path}.createdAt`); + if (typeof value.updatedAt !== "string" || value.updatedAt.trim().length === 0) return invalidField(`${path}.updatedAt`); + if (value.parentId !== undefined && typeof value.parentId !== "string") return invalidField(`${path}.parentId`); + return null; +} + +/** Documented. */ +function validateRoleApproval(value: unknown, path: string): string | null { + if (!isRecord(value)) return invalidField(path); + const extraKey = unexpectedKey(value, ["roleId", "sectionId", "status", "reviewerId", "comment", "updatedAt"], path); + if (extraKey) return extraKey; + if (typeof value.roleId !== "string" || value.roleId.trim().length === 0) return invalidField(`${path}.roleId`); + if (typeof value.sectionId !== "string" || value.sectionId.trim().length === 0) return invalidField(`${path}.sectionId`); + if (!isOneOf(APPROVAL_STATUSES, value.status)) return invalidField(`${path}.status`); + if (typeof value.reviewerId !== "string" || value.reviewerId.trim().length === 0) return invalidField(`${path}.reviewerId`); + if (typeof value.comment !== "string") return invalidField(`${path}.comment`); + if (typeof value.updatedAt !== "string" || value.updatedAt.trim().length === 0) return invalidField(`${path}.updatedAt`); + return null; +} + +/** Documented. */ +function validatePlayerAssignment(value: unknown, path: string): string | null { + if (!isRecord(value)) return invalidField(path); + const extraKey = unexpectedKey(value, ["id", "participantId", "roleId", "status", "notes", "assignedAt"], path); + if (extraKey) return extraKey; + if (typeof value.id !== "string" || value.id.trim().length === 0) return invalidField(`${path}.id`); + if (typeof value.participantId !== "string" || value.participantId.trim().length === 0) return invalidField(`${path}.participantId`); + if (typeof value.roleId !== "string" || value.roleId.trim().length === 0) return invalidField(`${path}.roleId`); + if (!isOneOf(ASSIGNMENT_STATUSES, value.status)) return invalidField(`${path}.status`); + if (typeof value.notes !== "string") return invalidField(`${path}.notes`); + if (typeof value.assignedAt !== "string" || value.assignedAt.trim().length === 0) return invalidField(`${path}.assignedAt`); + return null; +} + +/** Documented. */ +function validateCollaborationSession(value: unknown): string | null { + if (!isRecord(value)) return invalidField("root"); + const extraKey = unexpectedKey( + value, + ["id", "workspaceId", "state", "participants", "comments", "approvals", "assignments", "createdAt", "updatedAt"], + "" + ); + if (extraKey) return extraKey; + if (typeof value.id !== "string" || value.id.trim().length === 0) return invalidField("id"); + if (typeof value.workspaceId !== "string" || value.workspaceId.trim().length === 0) return invalidField("workspaceId"); + if (!isOneOf(COLLABORATION_SESSION_STATES, value.state)) return invalidField("state"); + if (typeof value.createdAt !== "string" || value.createdAt.trim().length === 0) return invalidField("createdAt"); + if (typeof value.updatedAt !== "string" || value.updatedAt.trim().length === 0) return invalidField("updatedAt"); + + if (!isDenseArray(value.participants)) return invalidField("participants"); + for (const [index, p] of value.participants.entries()) { + const pError = validateCollaborationParticipant(p, `participants[${index}]`); + if (pError) return pError; + } + + if (!isDenseArray(value.comments)) return invalidField("comments"); + for (const [index, c] of value.comments.entries()) { + const cError = validateRehearsalComment(c, `comments[${index}]`); + if (cError) return cError; + } + + if (!isDenseArray(value.approvals)) return invalidField("approvals"); + for (const [index, a] of value.approvals.entries()) { + const aError = validateRoleApproval(a, `approvals[${index}]`); + if (aError) return aError; + } + + if (!isDenseArray(value.assignments)) return invalidField("assignments"); + for (const [index, assign] of value.assignments.entries()) { + const assignError = validatePlayerAssignment(assign, `assignments[${index}]`); + if (assignError) return assignError; + } + + return null; +} + +/** Documented. */ +export function isCollaborationSession(value: unknown): value is CollaborationSession { + return validateCollaborationSession(value) === null; +} + +/** Documented. */ +export function parseCollaborationSession(value: unknown): CollaborationSession { + const validationError = validateCollaborationSession(value); + if (validationError) throw new Error(validationError); + return structuredClone(value as CollaborationSession); +} + +/** Documented. */ +export function createCollaborationSession(input: { + id: string; + workspaceId: string; +}): CollaborationSession { + const now = new Date().toISOString(); + return { + id: input.id, + workspaceId: input.workspaceId, + state: "active", + participants: [], + comments: [], + approvals: [], + assignments: [], + createdAt: now, + updatedAt: now + }; +} + +/** Documented. */ +export function createRehearsalComment(input: { + id: string; + authorId: string; + target: CommentTarget; + body: string; + parentId?: string; +}): RehearsalComment { + const now = new Date().toISOString(); + return { + id: input.id, + authorId: input.authorId, + target: input.target, + body: input.body, + status: "active", + createdAt: now, + updatedAt: now, + ...(input.parentId !== undefined ? { parentId: input.parentId } : {}) + }; +} + +/** Documented. */ +export function createPlayerAssignment(input: { + id: string; + participantId: string; + roleId: string; + notes?: string; +}): PlayerAssignment { + return { + id: input.id, + participantId: input.participantId, + roleId: input.roleId, + status: "assigned", + notes: input.notes ?? "", + assignedAt: new Date().toISOString() + }; +} + +/** Documented. */ +export function createRoleApproval(input: { + roleId: string; + sectionId: string; + reviewerId: string; + status?: ApprovalStatus; + comment?: string; +}): RoleApproval { + return { + roleId: input.roleId, + sectionId: input.sectionId, + status: input.status ?? "pending", + reviewerId: input.reviewerId, + comment: input.comment ?? "", + updatedAt: new Date().toISOString() + }; +} diff --git a/packages/shared-types/test/index.test.ts b/packages/shared-types/test/index.test.ts index 4df7226d..0c19ecfb 100644 --- a/packages/shared-types/test/index.test.ts +++ b/packages/shared-types/test/index.test.ts @@ -23,7 +23,19 @@ import { type LocalAudioSource, type RehearsalSong, MAX_SECTION_TIME_SECONDS, - SUPPORTED_AUDIO_FORMATS + SUPPORTED_AUDIO_FORMATS, + createCollaborationSession, + createRehearsalComment, + createPlayerAssignment, + createRoleApproval, + isCollaborationSession, + parseCollaborationSession, + type CollaborationSession, + COMMENT_STATUSES, + APPROVAL_STATUSES, + ASSIGNMENT_STATUSES, + COLLABORATION_SESSION_STATES, + COMMENT_TARGET_KINDS } from "../src/index"; describe("shared type helpers", () => { @@ -1196,4 +1208,374 @@ describe("shared type helpers", () => { expect(() => parseSongRehearsalPack({ ...validPack, id: 123 })).toThrow("id"); expect(() => parseSongRehearsalPack({ ...validPack, sourceLabel: 123 })).toThrow("sourceLabel"); }); + + it("creates and validates collaboration sessions with participants, comments, approvals, and assignments", () => { + const session = createCollaborationSession({ + id: "session-1", + workspaceId: "workspace-1" + }); + + expect(session.id).toBe("session-1"); + expect(session.workspaceId).toBe("workspace-1"); + expect(session.state).toBe("active"); + expect(session.participants).toEqual([]); + expect(session.comments).toEqual([]); + expect(session.approvals).toEqual([]); + expect(session.assignments).toEqual([]); + expect(session.createdAt).toBeTruthy(); + expect(session.updatedAt).toBeTruthy(); + expect(isCollaborationSession(session)).toBe(true); + + // Test with full data + const fullSession: CollaborationSession = { + id: "session-full", + workspaceId: "workspace-1", + state: "active", + participants: [ + { id: "p-1", displayName: "Alice", role: "bass" }, + { id: "p-2", displayName: "Bob", role: "keys" } + ], + comments: [ + { + id: "c-1", + authorId: "p-1", + target: { kind: "section", sectionId: "verse-1" }, + body: "Needs more punch here", + status: "active", + createdAt: "2026-06-15T10:00:00.000Z", + updatedAt: "2026-06-15T10:00:00.000Z" + }, + { + id: "c-2", + authorId: "p-2", + target: { kind: "role", sectionId: "verse-1", roleId: "bass-guitar" }, + body: "I'll simplify this run", + status: "resolved", + createdAt: "2026-06-15T10:05:00.000Z", + updatedAt: "2026-06-15T10:10:00.000Z", + parentId: "c-1" + }, + { + id: "c-3", + authorId: "p-1", + target: { kind: "song" }, + body: "Overall feels good", + status: "active", + createdAt: "2026-06-15T11:00:00.000Z", + updatedAt: "2026-06-15T11:00:00.000Z" + } + ], + approvals: [ + { + roleId: "bass-guitar", + sectionId: "verse-1", + status: "approved", + reviewerId: "p-1", + comment: "Sounds good", + updatedAt: "2026-06-15T12:00:00.000Z" + } + ], + assignments: [ + { + id: "a-1", + participantId: "p-1", + roleId: "bass-guitar", + status: "in_progress", + notes: "Focus on the turnaround", + assignedAt: "2026-06-15T09:00:00.000Z" + } + ], + createdAt: "2026-06-15T08:00:00.000Z", + updatedAt: "2026-06-15T12:00:00.000Z" + }; + + expect(isCollaborationSession(fullSession)).toBe(true); + expect(parseCollaborationSession(fullSession)).toEqual(fullSession); + + // Constant arrays are exported correctly + expect(COMMENT_STATUSES).toEqual(["active", "resolved", "archived"]); + expect(APPROVAL_STATUSES).toEqual(["pending", "approved", "changes_requested"]); + expect(ASSIGNMENT_STATUSES).toEqual(["assigned", "in_progress", "done"]); + expect(COLLABORATION_SESSION_STATES).toEqual(["active", "paused", "closed"]); + expect(COMMENT_TARGET_KINDS).toEqual(["section", "role", "song"]); + }); + + it("creates rehearsal comments with various target kinds", () => { + const sectionComment = createRehearsalComment({ + id: "c-1", + authorId: "author-1", + target: { kind: "section", sectionId: "verse-1" }, + body: "Let's work on timing here" + }); + expect(sectionComment.status).toBe("active"); + expect(sectionComment.target.kind).toBe("section"); + expect(sectionComment.parentId).toBeUndefined(); + + const replyComment = createRehearsalComment({ + id: "c-2", + authorId: "author-2", + target: { kind: "role", sectionId: "verse-1", roleId: "bass-guitar" }, + body: "Agreed, I'll simplify", + parentId: "c-1" + }); + expect(replyComment.parentId).toBe("c-1"); + expect(replyComment.target.roleId).toBe("bass-guitar"); + + const songComment = createRehearsalComment({ + id: "c-3", + authorId: "author-1", + target: { kind: "song" }, + body: "Great rehearsal!" + }); + expect(songComment.target.kind).toBe("song"); + }); + + it("creates player assignments and role approvals", () => { + const assignment = createPlayerAssignment({ + id: "a-1", + participantId: "p-1", + roleId: "bass-guitar", + notes: "Focus on the bridge section" + }); + expect(assignment.status).toBe("assigned"); + expect(assignment.notes).toBe("Focus on the bridge section"); + expect(assignment.assignedAt).toBeTruthy(); + + const noNotesAssignment = createPlayerAssignment({ + id: "a-2", + participantId: "p-2", + roleId: "keys-right" + }); + expect(noNotesAssignment.notes).toBe(""); + + const approval = createRoleApproval({ + roleId: "bass-guitar", + sectionId: "verse-1", + reviewerId: "p-2", + status: "approved", + comment: "Sounds great after simplification" + }); + expect(approval.status).toBe("approved"); + expect(approval.comment).toBe("Sounds great after simplification"); + + const pendingApproval = createRoleApproval({ + roleId: "keys-right", + sectionId: "chorus-1", + reviewerId: "p-1" + }); + expect(pendingApproval.status).toBe("pending"); + expect(pendingApproval.comment).toBe(""); + }); + + it("validates collaboration session fields and rejects invalid payloads", () => { + const validSession: CollaborationSession = { + id: "session-1", + workspaceId: "workspace-1", + state: "active", + participants: [{ id: "p-1", displayName: "Alice", role: "bass" }], + comments: [{ + id: "c-1", + authorId: "p-1", + target: { kind: "song" }, + body: "Hello", + status: "active", + createdAt: "2026-06-15T10:00:00.000Z", + updatedAt: "2026-06-15T10:00:00.000Z" + }], + approvals: [{ + roleId: "bass-guitar", + sectionId: "verse-1", + status: "pending", + reviewerId: "p-1", + comment: "", + updatedAt: "2026-06-15T12:00:00.000Z" + }], + assignments: [{ + id: "a-1", + participantId: "p-1", + roleId: "bass-guitar", + status: "assigned", + notes: "", + assignedAt: "2026-06-15T09:00:00.000Z" + }], + createdAt: "2026-06-15T08:00:00.000Z", + updatedAt: "2026-06-15T12:00:00.000Z" + }; + + expect(isCollaborationSession(validSession)).toBe(true); + expect(parseCollaborationSession(validSession)).toEqual(validSession); + + // Invalid root + expect(isCollaborationSession(null)).toBe(false); + expect(() => parseCollaborationSession(null)).toThrow("root"); + + // Extra fields + expect(() => parseCollaborationSession({ ...validSession, extraField: true })).toThrow("extraField"); + + // Invalid top-level fields + expect(() => parseCollaborationSession({ ...validSession, id: "" })).toThrow("id"); + expect(() => parseCollaborationSession({ ...validSession, id: " " })).toThrow("id"); + expect(() => parseCollaborationSession({ ...validSession, workspaceId: "" })).toThrow("workspaceId"); + expect(() => parseCollaborationSession({ ...validSession, state: "unknown" })).toThrow("state"); + expect(() => parseCollaborationSession({ ...validSession, createdAt: "" })).toThrow("createdAt"); + expect(() => parseCollaborationSession({ ...validSession, updatedAt: " " })).toThrow("updatedAt"); + + // Invalid participants + expect(() => parseCollaborationSession({ ...validSession, participants: "bad" })).toThrow("participants"); + expect(() => parseCollaborationSession({ ...validSession, participants: [null] })).toThrow("participants[0]"); + expect(() => parseCollaborationSession({ + ...validSession, + participants: [{ id: "", displayName: "Alice", role: "bass" }] + })).toThrow("participants[0].id"); + expect(() => parseCollaborationSession({ + ...validSession, + participants: [{ id: "p-1", displayName: " ", role: "bass" }] + })).toThrow("participants[0].displayName"); + expect(() => parseCollaborationSession({ + ...validSession, + participants: [{ id: "p-1", displayName: "Alice", role: 42 }] + })).toThrow("participants[0].role"); + expect(() => parseCollaborationSession({ + ...validSession, + participants: [{ id: "p-1", displayName: "Alice", role: "bass", extraField: true }] + })).toThrow("participants[0].extraField"); + + // Invalid comments + expect(() => parseCollaborationSession({ ...validSession, comments: "bad" })).toThrow("comments"); + expect(() => parseCollaborationSession({ ...validSession, comments: [null] })).toThrow("comments[0]"); + expect(() => parseCollaborationSession({ + ...validSession, + comments: [{ ...validSession.comments[0], id: "" }] + })).toThrow("comments[0].id"); + expect(() => parseCollaborationSession({ + ...validSession, + comments: [{ ...validSession.comments[0], authorId: " " }] + })).toThrow("comments[0].authorId"); + expect(() => parseCollaborationSession({ + ...validSession, + comments: [{ ...validSession.comments[0], target: null }] + })).toThrow("comments[0].target"); + expect(() => parseCollaborationSession({ + ...validSession, + comments: [{ ...validSession.comments[0], target: { kind: "invalid" } }] + })).toThrow("comments[0].target.kind"); + expect(() => parseCollaborationSession({ + ...validSession, + comments: [{ ...validSession.comments[0], body: 42 }] + })).toThrow("comments[0].body"); + expect(() => parseCollaborationSession({ + ...validSession, + comments: [{ ...validSession.comments[0], status: "deleted" }] + })).toThrow("comments[0].status"); + expect(() => parseCollaborationSession({ + ...validSession, + comments: [{ ...validSession.comments[0], createdAt: "" }] + })).toThrow("comments[0].createdAt"); + expect(() => parseCollaborationSession({ + ...validSession, + comments: [{ ...validSession.comments[0], updatedAt: " " }] + })).toThrow("comments[0].updatedAt"); + expect(() => parseCollaborationSession({ + ...validSession, + comments: [{ ...validSession.comments[0], parentId: 42 }] + })).toThrow("comments[0].parentId"); + expect(() => parseCollaborationSession({ + ...validSession, + comments: [{ ...validSession.comments[0], extraField: true }] + })).toThrow("comments[0].extraField"); + + // Comment target: section must have sectionId + expect(() => parseCollaborationSession({ + ...validSession, + comments: [{ ...validSession.comments[0], target: { kind: "section" } }] + })).toThrow("comments[0].target.sectionId"); + + // Comment target: role must have sectionId and roleId + expect(() => parseCollaborationSession({ + ...validSession, + comments: [{ ...validSession.comments[0], target: { kind: "role", sectionId: "verse-1" } }] + })).toThrow("comments[0].target.roleId"); + + // Comment target: extra field rejection + expect(() => parseCollaborationSession({ + ...validSession, + comments: [{ ...validSession.comments[0], target: { kind: "song", extraField: true } }] + })).toThrow("comments[0].target.extraField"); + + // Comment target: invalid sectionId type + expect(() => parseCollaborationSession({ + ...validSession, + comments: [{ ...validSession.comments[0], target: { kind: "section", sectionId: 42 } }] + })).toThrow("comments[0].target.sectionId"); + + // Comment target: invalid roleId type + expect(() => parseCollaborationSession({ + ...validSession, + comments: [{ ...validSession.comments[0], target: { kind: "role", sectionId: "verse-1", roleId: 42 } }] + })).toThrow("comments[0].target.roleId"); + + // Invalid approvals + expect(() => parseCollaborationSession({ ...validSession, approvals: "bad" })).toThrow("approvals"); + expect(() => parseCollaborationSession({ ...validSession, approvals: [null] })).toThrow("approvals[0]"); + expect(() => parseCollaborationSession({ + ...validSession, + approvals: [{ ...validSession.approvals[0], roleId: "" }] + })).toThrow("approvals[0].roleId"); + expect(() => parseCollaborationSession({ + ...validSession, + approvals: [{ ...validSession.approvals[0], sectionId: " " }] + })).toThrow("approvals[0].sectionId"); + expect(() => parseCollaborationSession({ + ...validSession, + approvals: [{ ...validSession.approvals[0], status: "rejected" }] + })).toThrow("approvals[0].status"); + expect(() => parseCollaborationSession({ + ...validSession, + approvals: [{ ...validSession.approvals[0], reviewerId: "" }] + })).toThrow("approvals[0].reviewerId"); + expect(() => parseCollaborationSession({ + ...validSession, + approvals: [{ ...validSession.approvals[0], comment: 42 }] + })).toThrow("approvals[0].comment"); + expect(() => parseCollaborationSession({ + ...validSession, + approvals: [{ ...validSession.approvals[0], updatedAt: "" }] + })).toThrow("approvals[0].updatedAt"); + expect(() => parseCollaborationSession({ + ...validSession, + approvals: [{ ...validSession.approvals[0], extraField: true }] + })).toThrow("approvals[0].extraField"); + + // Invalid assignments + expect(() => parseCollaborationSession({ ...validSession, assignments: "bad" })).toThrow("assignments"); + expect(() => parseCollaborationSession({ ...validSession, assignments: [null] })).toThrow("assignments[0]"); + expect(() => parseCollaborationSession({ + ...validSession, + assignments: [{ ...validSession.assignments[0], id: "" }] + })).toThrow("assignments[0].id"); + expect(() => parseCollaborationSession({ + ...validSession, + assignments: [{ ...validSession.assignments[0], participantId: " " }] + })).toThrow("assignments[0].participantId"); + expect(() => parseCollaborationSession({ + ...validSession, + assignments: [{ ...validSession.assignments[0], roleId: "" }] + })).toThrow("assignments[0].roleId"); + expect(() => parseCollaborationSession({ + ...validSession, + assignments: [{ ...validSession.assignments[0], status: "cancelled" }] + })).toThrow("assignments[0].status"); + expect(() => parseCollaborationSession({ + ...validSession, + assignments: [{ ...validSession.assignments[0], notes: 42 }] + })).toThrow("assignments[0].notes"); + expect(() => parseCollaborationSession({ + ...validSession, + assignments: [{ ...validSession.assignments[0], assignedAt: "" }] + })).toThrow("assignments[0].assignedAt"); + expect(() => parseCollaborationSession({ + ...validSession, + assignments: [{ ...validSession.assignments[0], extraField: true }] + })).toThrow("assignments[0].extraField"); + }); }); From 808029799cfc6c5c5f0c8d98126d91dc2f4913b8 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:28:28 +0000 Subject: [PATCH 3/4] Add collaboration UI components, i18n strings, and tests - CommentThread: displays threaded comments with resolve actions - AssignmentPanel: shows role assignments with status badges - ApprovalList: shows section/role approvals with reviewer info - CollaborationPanel: composes all sub-components into unified view - i18n: add EN and KO translations for all collaboration UI strings - Tests: 17 component tests covering all collaboration UI states Agent-Logs-Url: https://github.com/Seongho-Bae/bandscope/sessions/1cfe246a-04a9-4a53-8861-1496bc130f1f Co-authored-by: seonghobae <8172694+seonghobae@users.noreply.github.com> --- .../features/collaboration/ApprovalList.tsx | 82 +++++ .../collaboration/AssignmentPanel.tsx | 78 +++++ .../collaboration/CollaborationPanel.test.tsx | 280 ++++++++++++++++++ .../collaboration/CollaborationPanel.tsx | 101 +++++++ .../features/collaboration/CommentThread.tsx | 108 +++++++ .../src/features/collaboration/index.ts | 4 + apps/desktop/src/locales/en/common.json | 22 +- apps/desktop/src/locales/ko/common.json | 22 +- 8 files changed, 695 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/features/collaboration/ApprovalList.tsx create mode 100644 apps/desktop/src/features/collaboration/AssignmentPanel.tsx create mode 100644 apps/desktop/src/features/collaboration/CollaborationPanel.test.tsx create mode 100644 apps/desktop/src/features/collaboration/CollaborationPanel.tsx create mode 100644 apps/desktop/src/features/collaboration/CommentThread.tsx create mode 100644 apps/desktop/src/features/collaboration/index.ts diff --git a/apps/desktop/src/features/collaboration/ApprovalList.tsx b/apps/desktop/src/features/collaboration/ApprovalList.tsx new file mode 100644 index 00000000..8f8f8f1d --- /dev/null +++ b/apps/desktop/src/features/collaboration/ApprovalList.tsx @@ -0,0 +1,82 @@ +import { useMemo } from "react"; +import type { RoleApproval, CollaborationParticipant } from "@bandscope/shared-types"; +import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { Badge } from "@/components/ui/badge"; +import { ClipboardCheck } from "lucide-react"; + +interface ApprovalListProps { + approvals: RoleApproval[]; + participants: CollaborationParticipant[]; + roleNames: Map; + sectionLabels: Map; +} + +/** Documented. */ +function getReviewerName(reviewerId: string, participants: CollaborationParticipant[]): string { + return participants.find(p => p.id === reviewerId)?.displayName ?? reviewerId; +} + +/** Documented. */ +export function ApprovalList({ approvals, participants, roleNames, sectionLabels }: ApprovalListProps) { + const t = useMemo(() => createTranslator(detectPreferredLocale()), []); + + if (approvals.length === 0) { + return ( +
+ +

{t("approvalsEmptyState")}

+
+ ); + } + + /** Documented. */ + const getStatusBadge = (status: RoleApproval["status"]) => { + switch (status) { + case "pending": + return ( + + {t("approvalStatusPending")} + + ); + case "approved": + return ( + + {t("approvalStatusApproved")} + + ); + case "changes_requested": + return ( + + {t("approvalStatusChangesRequested")} + + ); + } + }; + + return ( +
+ {approvals.map((approval, index) => ( +
+
+ + {roleNames.get(approval.roleId) ?? approval.roleId} + + + {sectionLabels.get(approval.sectionId) ?? approval.sectionId} + + + {getReviewerName(approval.reviewerId, participants)} + + {approval.comment && ( + {approval.comment} + )} +
+ {getStatusBadge(approval.status)} +
+ ))} +
+ ); +} diff --git a/apps/desktop/src/features/collaboration/AssignmentPanel.tsx b/apps/desktop/src/features/collaboration/AssignmentPanel.tsx new file mode 100644 index 00000000..130b0394 --- /dev/null +++ b/apps/desktop/src/features/collaboration/AssignmentPanel.tsx @@ -0,0 +1,78 @@ +import { useMemo } from "react"; +import type { PlayerAssignment, CollaborationParticipant } from "@bandscope/shared-types"; +import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { Badge } from "@/components/ui/badge"; +import { Users } from "lucide-react"; + +interface AssignmentPanelProps { + assignments: PlayerAssignment[]; + participants: CollaborationParticipant[]; + roleNames: Map; +} + +/** Documented. */ +function getParticipantName(participantId: string, participants: CollaborationParticipant[]): string { + return participants.find(p => p.id === participantId)?.displayName ?? participantId; +} + +/** Documented. */ +export function AssignmentPanel({ assignments, participants, roleNames }: AssignmentPanelProps) { + const t = useMemo(() => createTranslator(detectPreferredLocale()), []); + + if (assignments.length === 0) { + return ( +
+ +

{t("assignmentsEmptyState")}

+
+ ); + } + + /** Documented. */ + const getStatusBadge = (status: PlayerAssignment["status"]) => { + switch (status) { + case "assigned": + return ( + + {t("assignmentStatusAssigned")} + + ); + case "in_progress": + return ( + + {t("assignmentStatusInProgress")} + + ); + case "done": + return ( + + {t("assignmentStatusDone")} + + ); + } + }; + + return ( +
+ {assignments.map(assignment => ( +
+
+ + {getParticipantName(assignment.participantId, participants)} + + + {roleNames.get(assignment.roleId) ?? assignment.roleId} + + {assignment.notes && ( + {assignment.notes} + )} +
+ {getStatusBadge(assignment.status)} +
+ ))} +
+ ); +} diff --git a/apps/desktop/src/features/collaboration/CollaborationPanel.test.tsx b/apps/desktop/src/features/collaboration/CollaborationPanel.test.tsx new file mode 100644 index 00000000..0d7e6e79 --- /dev/null +++ b/apps/desktop/src/features/collaboration/CollaborationPanel.test.tsx @@ -0,0 +1,280 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { CollaborationPanel } from "./CollaborationPanel"; +import { CommentThread } from "./CommentThread"; +import { AssignmentPanel } from "./AssignmentPanel"; +import { ApprovalList } from "./ApprovalList"; +import type { CollaborationSession, RehearsalSong } from "@bandscope/shared-types"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; + +const mockSession: CollaborationSession = { + id: "session-1", + workspaceId: "workspace-1", + state: "active", + participants: [ + { id: "p-1", displayName: "Alice", role: "bass" }, + { id: "p-2", displayName: "Bob", role: "keys" } + ], + comments: [ + { + id: "c-1", + authorId: "p-1", + target: { kind: "section", sectionId: "verse-1" }, + body: "Needs more punch here", + status: "active", + createdAt: "2026-06-15T10:00:00.000Z", + updatedAt: "2026-06-15T10:00:00.000Z" + }, + { + id: "c-2", + authorId: "p-2", + target: { kind: "role", sectionId: "verse-1", roleId: "bass-guitar" }, + body: "I'll simplify this run", + status: "resolved", + createdAt: "2026-06-15T10:05:00.000Z", + updatedAt: "2026-06-15T10:10:00.000Z", + parentId: "c-1" + } + ], + approvals: [ + { + roleId: "bass-guitar", + sectionId: "verse-1", + status: "approved", + reviewerId: "p-1", + comment: "Sounds good", + updatedAt: "2026-06-15T12:00:00.000Z" + } + ], + assignments: [ + { + id: "a-1", + participantId: "p-1", + roleId: "bass-guitar", + status: "in_progress", + notes: "Focus on the turnaround", + assignedAt: "2026-06-15T09:00:00.000Z" + } + ], + createdAt: "2026-06-15T08:00:00.000Z", + updatedAt: "2026-06-15T12:00:00.000Z" +}; + +describe("CollaborationPanel", () => { + const song: RehearsalSong = createDemoRehearsalSong(); + + it("renders empty state when no session is provided", () => { + render(); + expect(screen.getByTestId("collaboration-empty")).toBeTruthy(); + }); + + it("renders the full collaboration panel with session data", () => { + render(); + expect(screen.getByTestId("collaboration-panel")).toBeTruthy(); + expect(screen.getByText("Collaboration")).toBeTruthy(); + expect(screen.getByText("Comments")).toBeTruthy(); + expect(screen.getByText("Assignments")).toBeTruthy(); + expect(screen.getByText("Approvals")).toBeTruthy(); + }); + + it("displays participant count", () => { + render(); + expect(screen.getByText("2 participants")).toBeTruthy(); + }); +}); + +describe("CommentThread", () => { + it("renders empty state when no comments are provided", () => { + render(); + expect(screen.getByTestId("comments-empty")).toBeTruthy(); + }); + + it("renders top-level comments with author names", () => { + render( + + ); + expect(screen.getByTestId("comment-thread")).toBeTruthy(); + expect(screen.getByText("Alice")).toBeTruthy(); + expect(screen.getByText("Needs more punch here")).toBeTruthy(); + }); + + it("renders nested replies under parent comments", () => { + render( + + ); + expect(screen.getByText("Bob")).toBeTruthy(); + expect(screen.getByText("I'll simplify this run")).toBeTruthy(); + }); + + it("shows resolved badge for resolved comments", () => { + const resolvedComment = { + ...mockSession.comments[0], + id: "c-resolved", + status: "resolved" as const + }; + render( + + ); + expect(screen.getByText("Resolved")).toBeTruthy(); + }); + + it("calls onResolve when resolve button is clicked", () => { + const onResolve = vi.fn(); + render( + + ); + const resolveButton = screen.getByRole("button", { name: /resolve/i }); + fireEvent.click(resolveButton); + expect(onResolve).toHaveBeenCalledWith("c-1"); + }); + + it("falls back to authorId when participant is not found", () => { + const unknownAuthorComment = { + ...mockSession.comments[0], + id: "c-unknown", + authorId: "unknown-person" + }; + render( + + ); + expect(screen.getByText("unknown-person")).toBeTruthy(); + }); +}); + +describe("AssignmentPanel", () => { + const roleNames = new Map([["bass-guitar", "Bass Guitar"]]); + + it("renders empty state when no assignments are provided", () => { + render( + + ); + expect(screen.getByTestId("assignments-empty")).toBeTruthy(); + }); + + it("renders assignments with participant names and role info", () => { + render( + + ); + expect(screen.getByTestId("assignment-panel")).toBeTruthy(); + expect(screen.getByText("Alice")).toBeTruthy(); + expect(screen.getByText("Bass Guitar")).toBeTruthy(); + expect(screen.getByText("In progress")).toBeTruthy(); + }); + + it("shows assignment notes when present", () => { + render( + + ); + expect(screen.getByText("Focus on the turnaround")).toBeTruthy(); + }); + + it("renders all assignment status types", () => { + const allStatusAssignments = [ + { ...mockSession.assignments[0], id: "a-assigned", status: "assigned" as const }, + { ...mockSession.assignments[0], id: "a-progress", status: "in_progress" as const }, + { ...mockSession.assignments[0], id: "a-done", status: "done" as const } + ]; + render( + + ); + expect(screen.getByText("Assigned")).toBeTruthy(); + expect(screen.getByText("In progress")).toBeTruthy(); + expect(screen.getByText("Done")).toBeTruthy(); + }); +}); + +describe("ApprovalList", () => { + const roleNames = new Map([["bass-guitar", "Bass Guitar"]]); + const sectionLabels = new Map([["verse-1", "verse"]]); + + it("renders empty state when no approvals are provided", () => { + render( + + ); + expect(screen.getByTestId("approvals-empty")).toBeTruthy(); + }); + + it("renders approvals with role name, section label, and status", () => { + render( + + ); + expect(screen.getByTestId("approval-list")).toBeTruthy(); + expect(screen.getByText("Bass Guitar")).toBeTruthy(); + expect(screen.getByText("verse")).toBeTruthy(); + expect(screen.getByText("Approved")).toBeTruthy(); + }); + + it("shows reviewer name and comment", () => { + render( + + ); + expect(screen.getByText("Alice")).toBeTruthy(); + expect(screen.getByText("Sounds good")).toBeTruthy(); + }); + + it("renders all approval status types", () => { + const allStatusApprovals = [ + { ...mockSession.approvals[0], status: "pending" as const }, + { ...mockSession.approvals[0], status: "approved" as const }, + { ...mockSession.approvals[0], status: "changes_requested" as const } + ]; + render( + + ); + expect(screen.getByText("Pending review")).toBeTruthy(); + expect(screen.getByText("Approved")).toBeTruthy(); + expect(screen.getByText("Changes requested")).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/collaboration/CollaborationPanel.tsx b/apps/desktop/src/features/collaboration/CollaborationPanel.tsx new file mode 100644 index 00000000..21d063f9 --- /dev/null +++ b/apps/desktop/src/features/collaboration/CollaborationPanel.tsx @@ -0,0 +1,101 @@ +import { useMemo } from "react"; +import type { CollaborationSession, RehearsalSong } from "@bandscope/shared-types"; +import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { CommentThread } from "./CommentThread"; +import { AssignmentPanel } from "./AssignmentPanel"; +import { ApprovalList } from "./ApprovalList"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Users } from "lucide-react"; + +interface CollaborationPanelProps { + session: CollaborationSession | null; + song: RehearsalSong; + onResolveComment?: (commentId: string) => void; +} + +/** Documented. */ +export function CollaborationPanel({ session, song, onResolveComment }: CollaborationPanelProps) { + const t = useMemo(() => createTranslator(detectPreferredLocale()), []); + + const roleNames = useMemo(() => { + const map = new Map(); + for (const section of song.sections) { + for (const role of section.roles) { + if (!map.has(role.id)) { + map.set(role.id, role.name); + } + } + } + return map; + }, [song]); + + const sectionLabels = useMemo(() => { + const map = new Map(); + for (const section of song.sections) { + map.set(section.id, section.label); + } + return map; + }, [song]); + + if (!session) { + return ( + + + +

{t("collaborationEmptyState")}

+
+
+ ); + } + + return ( + + +
+

+ {t("collaborationTitle")} +

+ + {session.participants.length} {t("participantsTitle").toLowerCase()} + +
+
+ + +
+

+ {t("commentsTitle")} +

+ +
+ +
+

+ {t("assignmentsTitle")} +

+ +
+ +
+

+ {t("approvalsTitle")} +

+ +
+
+
+ ); +} diff --git a/apps/desktop/src/features/collaboration/CommentThread.tsx b/apps/desktop/src/features/collaboration/CommentThread.tsx new file mode 100644 index 00000000..f4c2fa91 --- /dev/null +++ b/apps/desktop/src/features/collaboration/CommentThread.tsx @@ -0,0 +1,108 @@ +import { useMemo } from "react"; +import type { RehearsalComment, CollaborationParticipant } from "@bandscope/shared-types"; +import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { MessageCircle, CheckCircle2 } from "lucide-react"; + +interface CommentThreadProps { + comments: RehearsalComment[]; + participants: CollaborationParticipant[]; + onResolve?: (commentId: string) => void; +} + +/** Documented. */ +function getAuthorName(authorId: string, participants: CollaborationParticipant[]): string { + return participants.find(p => p.id === authorId)?.displayName ?? authorId; +} + +/** Documented. */ +function formatCommentDate(isoDate: string): string { + const date = new Date(isoDate); + if (Number.isNaN(date.getTime())) return isoDate; + return date.toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); +} + +/** Documented. */ +export function CommentThread({ comments, participants, onResolve }: CommentThreadProps) { + const t = useMemo(() => createTranslator(detectPreferredLocale()), []); + + const topLevelComments = useMemo( + () => comments.filter(c => !c.parentId), + [comments] + ); + + const repliesByParent = useMemo(() => { + const map = new Map(); + for (const c of comments) { + if (c.parentId) { + const existing = map.get(c.parentId) ?? []; + existing.push(c); + map.set(c.parentId, existing); + } + } + return map; + }, [comments]); + + if (comments.length === 0) { + return ( +
+ +

{t("commentsEmptyState")}

+
+ ); + } + + return ( +
+ {topLevelComments.map(comment => ( +
+
+
+ + {getAuthorName(comment.authorId, participants)} + + + {formatCommentDate(comment.createdAt)} + +
+ {comment.status === "resolved" ? ( + + {t("commentResolved")} + + ) : onResolve ? ( + + ) : null} +
+

{comment.body}

+ + {repliesByParent.has(comment.id) && ( +
+ {repliesByParent.get(comment.id)!.map(reply => ( +
+
+ + {getAuthorName(reply.authorId, participants)} + + + {formatCommentDate(reply.createdAt)} + +
+

{reply.body}

+
+ ))} +
+ )} +
+ ))} +
+ ); +} diff --git a/apps/desktop/src/features/collaboration/index.ts b/apps/desktop/src/features/collaboration/index.ts new file mode 100644 index 00000000..4d842c3b --- /dev/null +++ b/apps/desktop/src/features/collaboration/index.ts @@ -0,0 +1,4 @@ +export { CollaborationPanel } from "./CollaborationPanel"; +export { CommentThread } from "./CommentThread"; +export { AssignmentPanel } from "./AssignmentPanel"; +export { ApprovalList } from "./ApprovalList"; diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index a5b8d143..c30cf8af 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -55,5 +55,25 @@ "youtubePlaceholder": "YouTube URL...", "importYoutube": "Import YouTube", "importingYoutube": "Importing...", - "youtubeImportFailed": "Failed to import YouTube URL." + "youtubeImportFailed": "Failed to import YouTube URL.", + "collaborationTitle": "Collaboration", + "collaborationEmptyState": "No collaboration session active. Start one to invite your bandmates.", + "commentsTitle": "Comments", + "commentsEmptyState": "No comments yet. Add a note for the band.", + "commentPlaceholder": "Add a rehearsal note...", + "commentSubmit": "Post", + "commentResolve": "Resolve", + "commentResolved": "Resolved", + "assignmentsTitle": "Assignments", + "assignmentsEmptyState": "No role assignments yet.", + "assignmentStatusAssigned": "Assigned", + "assignmentStatusInProgress": "In progress", + "assignmentStatusDone": "Done", + "approvalsTitle": "Approvals", + "approvalsEmptyState": "No approvals requested yet.", + "approvalStatusPending": "Pending review", + "approvalStatusApproved": "Approved", + "approvalStatusChangesRequested": "Changes requested", + "participantsTitle": "Participants", + "participantsEmptyState": "No participants have joined yet." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 3729c64b..8f7870f4 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -55,5 +55,25 @@ "youtubePlaceholder": "유튜브 URL...", "importYoutube": "유튜브 가져오기", "importingYoutube": "가져오는 중...", - "youtubeImportFailed": "유튜브 URL 가져오기에 실패했습니다." + "youtubeImportFailed": "유튜브 URL 가져오기에 실패했습니다.", + "collaborationTitle": "협업", + "collaborationEmptyState": "활성 협업 세션이 없습니다. 밴드 멤버를 초대하려면 세션을 시작하세요.", + "commentsTitle": "코멘트", + "commentsEmptyState": "아직 코멘트가 없습니다. 밴드를 위한 메모를 남겨보세요.", + "commentPlaceholder": "합주 메모 추가...", + "commentSubmit": "등록", + "commentResolve": "해결", + "commentResolved": "해결됨", + "assignmentsTitle": "역할 배정", + "assignmentsEmptyState": "아직 역할 배정이 없습니다.", + "assignmentStatusAssigned": "배정됨", + "assignmentStatusInProgress": "진행 중", + "assignmentStatusDone": "완료", + "approvalsTitle": "승인", + "approvalsEmptyState": "아직 승인 요청이 없습니다.", + "approvalStatusPending": "검토 대기 중", + "approvalStatusApproved": "승인됨", + "approvalStatusChangesRequested": "수정 요청됨", + "participantsTitle": "참여자", + "participantsEmptyState": "아직 참여한 멤버가 없습니다." } From 4927a556353597df6a588efc8f70bd163bb0fb7b Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:33:22 +0000 Subject: [PATCH 4/4] Fix validation error reporting for role comment targets Split combined sectionId/roleId check into separate validations so the correct field name is reported when either is missing. Agent-Logs-Url: https://github.com/Seongho-Bae/bandscope/sessions/1cfe246a-04a9-4a53-8861-1496bc130f1f Co-authored-by: seonghobae <8172694+seonghobae@users.noreply.github.com> --- packages/shared-types/src/index.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index b257c15a..d0bc2eca 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -1657,9 +1657,8 @@ function validateCommentTarget(value: unknown, path: string): string | null { if (value.sectionId !== undefined && typeof value.sectionId !== "string") return invalidField(`${path}.sectionId`); if (value.roleId !== undefined && typeof value.roleId !== "string") return invalidField(`${path}.roleId`); if (value.kind === "section" && typeof value.sectionId !== "string") return invalidField(`${path}.sectionId`); - if (value.kind === "role" && (typeof value.sectionId !== "string" || typeof value.roleId !== "string")) { - return invalidField(`${path}.roleId`); - } + if (value.kind === "role" && typeof value.sectionId !== "string") return invalidField(`${path}.sectionId`); + if (value.kind === "role" && typeof value.roleId !== "string") return invalidField(`${path}.roleId`); return null; }