diff --git a/server/src/__integration__/_helpers.ts b/server/src/__integration__/_helpers.ts index 4c961a27..dccc9fd7 100644 --- a/server/src/__integration__/_helpers.ts +++ b/server/src/__integration__/_helpers.ts @@ -31,6 +31,10 @@ export function ensureSchemaOnce(): Promise { * constraints; CASCADE on the parents handles it but listing explicitly * keeps the intent visible + lets us spot-check leakage. */ const TABLES_TO_WIPE: readonly string[] = [ + 'course_invitation_acceptances', + 'course_invitations', + 'course_members', + 'courses', 'agent_host_actions', 'agent_os_approvals', 'agent_os_session_leases', diff --git a/server/src/__integration__/courses.test.ts b/server/src/__integration__/courses.test.ts new file mode 100644 index 00000000..906935fe --- /dev/null +++ b/server/src/__integration__/courses.test.ts @@ -0,0 +1,297 @@ +import assert from 'node:assert/strict' +import { after, before, beforeEach, test } from 'node:test' +import { createServer, type Server } from 'node:http' +import { WebSocket, type RawData } from 'ws' +import * as Y from 'yjs' +import { buildApiTestApp, ensureSchemaOnce, resetAllTables, seedUserMembership, teardownAll } from './_helpers.js' +import { createWsTicket } from '../auth.js' +import { ensureSchema } from '../db/migrate.js' +import { pool } from '../db/pool.js' +import { applyLocalUpdate } from '../documents/rooms.js' +import { attachWebSocket } from '../ws.js' + +const OWNER = 'u-course-owner' +const LEARNER = 'u-course-learner' +let ownerServer: Server +let learnerServer: Server +let ownerUrl = '' +let learnerUrl = '' + +async function listen(userId: string, withWebSocket = false): Promise<{ server: Server; url: string }> { + const app = await buildApiTestApp(userId) + return await new Promise((resolve) => { + const server = createServer(app) + if (withWebSocket) attachWebSocket(server) + server.listen(0, () => { + const address = server.address() + resolve({ server, url: `http://127.0.0.1:${typeof address === 'object' && address ? address.port : 0}` }) + }) + }) +} + +before(async () => { + await ensureSchemaOnce() + const owner = await listen(OWNER); ownerServer = owner.server; ownerUrl = owner.url + const learner = await listen(LEARNER, true); learnerServer = learner.server; learnerUrl = learner.url +}) +beforeEach(resetAllTables) +after(async () => { await teardownAll(ownerServer); if (learnerServer.listening) await new Promise((resolve) => learnerServer.close(() => resolve())) }) + +async function seedCompany(companyId = 'co-courses'): Promise { + await pool.query(`INSERT INTO companies (id,name,slug,owner_user_id) VALUES ($1,'Course test',$1,$2)`, [companyId, OWNER]) + await seedUserMembership(OWNER, companyId, { email: 'owner@test.local', displayName: 'Owner' }) + await pool.query( + `INSERT INTO projects (id,company_id,name,description,color,created_by,is_general) + VALUES ($1,$2,'General','','#64748b',$3,TRUE)`, + [`general-${companyId}`, companyId, OWNER], + ) + await pool.query(`INSERT INTO users (id,email,display_name,tier,email_verified_at) VALUES ($1,$2,'Learner','pro',NOW())`, [LEARNER, 'learner@test.local']) +} + +async function createCourse(name: string, companyId = 'co-courses') { + const response = await fetch(`${ownerUrl}/api/courses`, { + method: 'POST', headers: { 'content-type': 'application/json', 'x-company-id': companyId }, + body: JSON.stringify({ name, description: `${name} description` }), + }) + const raw = await response.text() + assert.equal(response.status, 201, raw) + return JSON.parse(raw) as { id: string; projectId: string; studyRoomId: string } +} + +async function createInvitation(courseId: string, role: 'teacher' | 'learner', companyId = 'co-courses') { + const created = await fetch(`${ownerUrl}/api/courses/${courseId}/invitations`, { + method: 'POST', headers: { 'content-type': 'application/json', 'x-company-id': companyId }, + body: JSON.stringify({ email: 'learner@test.local', role, expiresInDays: 7, maxUses: 1 }), + }) + const createdRaw = await created.text() + assert.equal(created.status, 201, createdRaw) + return JSON.parse(createdRaw) as { token: string; id: string } +} + +async function inviteAndAccept(courseId: string, role: 'teacher' | 'learner', companyId = 'co-courses') { + const invitation = await createInvitation(courseId, role, companyId) + const accepted = await fetch(`${learnerUrl}/api/course-invitations/${encodeURIComponent(invitation.token)}/accept`, { method: 'POST' }) + const acceptedRaw = await accepted.text() + assert.equal(accepted.status, 200, acceptedRaw) + return invitation +} + +test('[integration] legacy Projects migrate to one idempotent Course and Study Room', async () => { + await pool.query(`DELETE FROM course_schema_cutovers WHERE id='course-model-v1'`) + await seedCompany('co-legacy') + await pool.query( + `INSERT INTO projects (id,company_id,name,description,color,created_by,is_general) + VALUES ('legacy-project','co-legacy','Legacy Biology','','#123456',$1,FALSE)`, [OWNER], + ) + await pool.query( + `INSERT INTO company_members (company_id,user_id,role) VALUES ('co-legacy',$1,'member')`, [LEARNER], + ) + await pool.query( + `INSERT INTO participants (id,company_id,kind,name,initial,avatar_bg,status) + VALUES ($1,'co-legacy','human','Learner','L','#aaa','avail')`, [LEARNER], + ) + + await ensureSchema() + const course = await pool.query<{ id: string; study_room_conversation_id: string }>( + `SELECT id,study_room_conversation_id FROM courses WHERE project_id='legacy-project'`, + ) + assert.equal(course.rowCount, 1) + const roles = await pool.query<{ user_id: string; role: string }>( + `SELECT user_id,role FROM course_members WHERE course_id=$1 ORDER BY user_id`, [course.rows[0].id], + ) + assert.deepEqual(new Map(roles.rows.map((row) => [row.user_id, row.role])), new Map([[LEARNER, 'learner'], [OWNER, 'teacher']])) + assert.match(course.rows[0].study_room_conversation_id, /^course-room-/) + assert.equal((await pool.query(`SELECT 1 FROM conversations WHERE id=$1 AND project_id='legacy-project'`, [course.rows[0].study_room_conversation_id])).rowCount, 1) + + await pool.query(`DELETE FROM course_members WHERE course_id=$1 AND user_id=$2`, [course.rows[0].id, LEARNER]) + await ensureSchema() + assert.equal((await pool.query( + `SELECT 1 FROM course_members WHERE course_id=$1 AND user_id=$2`, + [course.rows[0].id, LEARNER], + )).rowCount, 0) +}) + +test('[integration] learner sees only enrolled courses and receives opaque 404 for another Project', async () => { + await seedCompany() + const first = await createCourse('Physics') + const second = await createCourse('Chemistry') + await inviteAndAccept(first.id, 'learner') + await pool.query( + `INSERT INTO documents (id,company_id,project_id,title,created_by) VALUES + ('doc-first','co-courses',$1,'First',$3),('doc-second','co-courses',$2,'Second',$3)`, + [first.projectId, second.projectId, OWNER], + ) + + const courses = await fetch(`${learnerUrl}/api/courses`, { headers: { 'x-company-id': 'co-courses' } }) + assert.equal(courses.status, 200) + assert.deepEqual((await courses.json() as Array<{ id: string }>).map((course) => course.id), [first.id]) + const allowed = await fetch(`${learnerUrl}/api/documents`, { headers: { 'x-company-id': 'co-courses', 'x-project-id': first.projectId } }) + assert.deepEqual((await allowed.json() as { documents: Array<{ id: string }> }).documents.map((document) => document.id), ['doc-first']) + const denied = await fetch(`${learnerUrl}/api/documents/doc-second`, { headers: { 'x-company-id': 'co-courses', 'x-project-id': second.projectId } }) + assert.equal(denied.status, 404) +}) + +test('[integration] course invitation replay is idempotent and teacher upgrade never downgrades', async () => { + await seedCompany() + const course = await createCourse('Mathematics') + const learnerInvite = await inviteAndAccept(course.id, 'learner') + const replay = await fetch(`${learnerUrl}/api/course-invitations/${encodeURIComponent(learnerInvite.token)}/accept`, { method: 'POST' }) + assert.equal(replay.status, 200) + assert.equal((await pool.query(`SELECT use_count FROM course_invitations WHERE token_hash=$1`, [learnerInvite.id])).rows[0].use_count, 1) + + await inviteAndAccept(course.id, 'teacher') + assert.equal((await pool.query(`SELECT role FROM course_members WHERE course_id=$1 AND user_id=$2`, [course.id, LEARNER])).rows[0].role, 'teacher') + const downgrade = await inviteAndAccept(course.id, 'learner') + assert.equal((await pool.query(`SELECT role FROM course_members WHERE course_id=$1 AND user_id=$2`, [course.id, LEARNER])).rows[0].role, 'teacher') + assert.equal((await pool.query(`SELECT use_count FROM course_invitations WHERE token_hash=$1`, [downgrade.id])).rows[0].use_count, 0) + + await fetch(`${ownerUrl}/api/courses/${course.id}/archive`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-company-id': 'co-courses' }, body: '{}' }) + const write = await fetch(`${learnerUrl}/api/documents`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-company-id': 'co-courses', 'x-project-id': course.projectId }, body: JSON.stringify({ title: 'Blocked' }) }) + assert.equal(write.status, 409) +}) + +test('[integration] concurrent teacher and learner invitations preserve the teacher role', async () => { + await seedCompany() + const course = await createCourse('Concurrency') + const [teacherInvite, learnerInvite] = await Promise.all([ + createInvitation(course.id, 'teacher'), + createInvitation(course.id, 'learner'), + ]) + const responses = await Promise.all([teacherInvite, learnerInvite].map((invitation) => fetch( + `${learnerUrl}/api/course-invitations/${encodeURIComponent(invitation.token)}/accept`, + { method: 'POST' }, + ))) + assert.deepEqual(responses.map((response) => response.status), [200, 200]) + assert.equal((await pool.query( + `SELECT role FROM course_members WHERE course_id=$1 AND user_id=$2`, + [course.id, LEARNER], + )).rows[0].role, 'teacher') +}) + +test('[integration] removing a member invalidates replay of their consumed course invitation', async () => { + await seedCompany() + const course = await createCourse('Replay revocation') + const invitation = await inviteAndAccept(course.id, 'learner') + + const removed = await fetch(`${ownerUrl}/api/courses/${course.id}/members/${LEARNER}`, { + method: 'DELETE', headers: { 'x-company-id': 'co-courses' }, + }) + assert.equal(removed.status, 200, await removed.text()) + const replay = await fetch( + `${learnerUrl}/api/course-invitations/${encodeURIComponent(invitation.token)}/accept`, + { method: 'POST' }, + ) + assert.equal(replay.status, 410, await replay.text()) + assert.equal((await pool.query( + `SELECT 1 FROM course_members WHERE course_id=$1 AND user_id=$2`, + [course.id, LEARNER], + )).rowCount, 0) + + const visible = await fetch(`${learnerUrl}/api/courses`, { headers: { 'x-company-id': 'co-courses' } }) + assert.equal(visible.status, 200) + assert.deepEqual(await visible.json(), []) +}) + +test('[integration] concurrent company removals cannot delete every teacher from an active course', async () => { + await seedCompany() + const course = await createCourse('Teacher invariant') + const teachers = ['u-company-teacher-a', 'u-company-teacher-b'] + await pool.query( + `INSERT INTO users (id,email,display_name,tier,email_verified_at) VALUES + ($1,'teacher-a@test.local','Teacher A','pro',NOW()), + ($2,'teacher-b@test.local','Teacher B','pro',NOW())`, + teachers, + ) + await pool.query( + `INSERT INTO company_members (company_id,user_id,role) VALUES + ('co-courses',$1,'member'),('co-courses',$2,'member')`, + teachers, + ) + await pool.query( + `INSERT INTO course_members (course_id,company_id,user_id,role) VALUES + ($1,'co-courses',$2,'teacher'),($1,'co-courses',$3,'teacher')`, + [course.id, ...teachers], + ) + const removeOwnerFromCourse = await fetch(`${ownerUrl}/api/courses/${course.id}/members/${OWNER}`, { + method: 'DELETE', headers: { 'x-company-id': 'co-courses' }, + }) + assert.equal(removeOwnerFromCourse.status, 200, await removeOwnerFromCourse.text()) + + const removals = await Promise.all(teachers.map((teacherId) => fetch( + `${ownerUrl}/api/companies/co-courses/members/${teacherId}`, + { method: 'DELETE', headers: { 'x-company-id': 'co-courses' } }, + ))) + assert.deepEqual(removals.map((response) => response.status).sort(), [200, 409]) + assert.equal((await pool.query( + `SELECT COUNT(*)::int AS count FROM course_members WHERE course_id=$1 AND role='teacher'`, + [course.id], + )).rows[0].count, 1) + assert.equal((await pool.query( + `SELECT COUNT(*)::int AS count FROM company_members + WHERE company_id='co-courses' AND user_id=ANY($1::text[])`, + [teachers], + )).rows[0].count, 1) +}) + +function waitForSocketMessage( + socket: WebSocket, + predicate: (message: Record) => boolean, + timeoutMs = 2_000, +): Promise> { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + socket.off('message', onMessage) + reject(new Error('timed out waiting for WebSocket message')) + }, timeoutMs) + const onMessage = (raw: RawData) => { + let message: Record + try { message = JSON.parse(raw.toString()) as Record } catch { return } + if (!predicate(message)) return + clearTimeout(timeout) + socket.off('message', onMessage) + resolve(message) + } + socket.on('message', onMessage) + }) +} + +test('[integration] removing a course member revokes an existing document WebSocket subscription', async () => { + await seedCompany() + const course = await createCourse('Realtime security') + await inviteAndAccept(course.id, 'learner') + await pool.query( + `INSERT INTO documents (id,company_id,project_id,title,created_by) + VALUES ('doc-live','co-courses',$1,'Live document',$2)`, + [course.projectId, OWNER], + ) + + const { ticket } = await createWsTicket(LEARNER) + const socket = new WebSocket(`${learnerUrl.replace('http://', 'ws://')}/ws?t=${encodeURIComponent(ticket)}`) + await new Promise((resolve, reject) => { + socket.once('open', resolve) + socket.once('error', reject) + }) + const synced = waitForSocketMessage(socket, (message) => message.type === 'doc.sync' && message.documentId === 'doc-live') + socket.send(JSON.stringify({ type: 'doc.subscribe', documentId: 'doc-live' })) + await synced + + const removed = await fetch(`${ownerUrl}/api/courses/${course.id}/members/${LEARNER}`, { + method: 'DELETE', headers: { 'x-company-id': 'co-courses' }, + }) + assert.equal(removed.status, 200, await removed.text()) + assert.equal(socket.readyState, WebSocket.OPEN) + + const receivedUpdates: Record[] = [] + const capture = (raw: RawData) => { + const message = JSON.parse(raw.toString()) as Record + if (message.type === 'doc.update' && message.documentId === 'doc-live') receivedUpdates.push(message) + } + socket.on('message', capture) + const source = new Y.Doc() + source.getText('content').insert(0, 'must not leak') + await applyLocalUpdate('doc-live', 'co-courses', 'review-test', OWNER, Y.encodeStateAsUpdate(source)) + await new Promise((resolve) => setTimeout(resolve, 200)) + socket.off('message', capture) + assert.equal(receivedUpdates.length, 0) + socket.close() +}) diff --git a/server/src/__integration__/polls.test.ts b/server/src/__integration__/polls.test.ts index 54b10af1..c08e724b 100644 --- a/server/src/__integration__/polls.test.ts +++ b/server/src/__integration__/polls.test.ts @@ -95,6 +95,15 @@ async function voteViaHttp(messageId: string, optionIds: string[]): Promise<{ st return { status: res.status, body: await res.json().catch(() => null) } } +async function closeViaHttp(messageId: string): Promise<{ status: number; body: any }> { + const res = await fetch(`${baseUrl}/api/polls/${encodeURIComponent(messageId)}/close`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-company-id': COMPANY }, + body: '{}', + }) + return { status: res.status, body: await res.json().catch(() => null) } +} + test('[integration] POST /polls creates a poll message with structured payload', async () => { const { status, body } = await createPollViaHttp({ conversationId: CONVO, @@ -219,6 +228,29 @@ test('[integration] closing a poll blocks further votes; only author can close', assert.equal(vote.status, 409) }) +test('[integration] archived course conversations reject poll create, vote, and close writes', async () => { + await pool.query( + `INSERT INTO projects (id,company_id,name,description,color,created_by,is_general,status) + VALUES ('poll-course-project',$1,'Poll course','','#123456',$2,FALSE,'active')`, + [COMPANY, ME], + ) + await pool.query(`UPDATE conversations SET project_id='poll-course-project' WHERE id=$1`, [CONVO]) + const created = await createPollViaHttp({ + conversationId: CONVO, question: 'Before archive?', mode: 'single', options: ['Yes', 'No'], + }) + assert.equal(created.status, 201) + await pool.query(`UPDATE projects SET status='archived',archived_at=NOW() WHERE id='poll-course-project'`) + + const createBlocked = await createPollViaHttp({ + conversationId: CONVO, question: 'After archive?', mode: 'single', options: ['Yes', 'No'], + }) + assert.equal(createBlocked.status, 409) + const voteBlocked = await voteViaHttp(created.body.messageId, [created.body.poll.options[0].id]) + assert.equal(voteBlocked.status, 409) + const closeBlocked = await closeViaHttp(created.body.messageId) + assert.equal(closeBlocked.status, 409) +}) + test('[integration] sweepExpiredPolls auto-closes polls past expiresAt', async () => { const created = (await createPollViaHttp({ conversationId: CONVO, question: 'Now?', mode: 'single', diff --git a/server/src/agents/cli.ts b/server/src/agents/cli.ts index c80cbfb3..0e171d4b 100644 --- a/server/src/agents/cli.ts +++ b/server/src/agents/cli.ts @@ -3592,11 +3592,14 @@ async function cmdTopicSet(parsed: ParsedArgs): Promise { const raw = unescapeChat(parsed.positional.slice(1).join(' ')).trim() const topic = raw.length > 0 ? raw.slice(0, 200) : null - const { rows } = await pool.query<{ members: string[]; company_id: string }>( - `SELECT members, company_id FROM conversations WHERE id = $1`, [convoId], + const { rows } = await pool.query<{ members: string[]; company_id: string; project_id: string | null; project_status: string | null }>( + `SELECT conversation.members,conversation.company_id,conversation.project_id,project.status AS project_status + FROM conversations conversation LEFT JOIN projects project ON project.id=conversation.project_id + WHERE conversation.id=$1`, [convoId], ) if (!rows[0]) return err(`unknown conversation ${convoId}`) if (!rows[0].members.includes(me)) return err(`${me} is not a member of ${convoId}`) + if (rows[0].project_status === 'archived') return err('archived courses are read-only') await pool.query( `UPDATE conversations SET topic = $2, updated_at = NOW() WHERE id = $1`, @@ -3607,6 +3610,7 @@ async function cmdTopicSet(parsed: ParsedArgs): Promise { type: 'conversation.updated', conversationId: convoId, companyId: rows[0].company_id, + workspaceId: rows[0].project_id ?? undefined, patch: { topic }, }) return ok(topic ? `topic set: "${topic}"` : '(topic cleared)', [{ @@ -3629,12 +3633,16 @@ async function cmdRename(parsed: ParsedArgs): Promise { const title = unescapeChat(parsed.positional.slice(1).join(' ')).trim().slice(0, 80) if (!title) return err('rename requires a non-empty title') - const { rows } = await pool.query<{ members: string[]; kind: string; company_id: string; title: string }>( - `SELECT members, kind, company_id, title FROM conversations WHERE id = $1`, [convoId], + const { rows } = await pool.query<{ members: string[]; kind: string; company_id: string; title: string; project_id: string | null; project_status: string | null }>( + `SELECT conversation.members,conversation.kind,conversation.company_id,conversation.title, + conversation.project_id,project.status AS project_status + FROM conversations conversation LEFT JOIN projects project ON project.id=conversation.project_id + WHERE conversation.id=$1`, [convoId], ) if (!rows[0]) return err(`unknown conversation ${convoId}`) if (rows[0].kind !== 'group') return err(`only group chats can be renamed (${convoId} is a ${rows[0].kind})`) if (!rows[0].members.includes(me)) return err(`${me} is not a member of ${convoId}`) + if (rows[0].project_status === 'archived') return err('archived courses are read-only') const currentTitle = rows[0].title // Optimistic-concurrency: --if-equals "" lets a caller @@ -3668,6 +3676,7 @@ async function cmdRename(parsed: ParsedArgs): Promise { type: 'conversation.updated', conversationId: convoId, companyId: rows[0].company_id, + workspaceId: rows[0].project_id ?? undefined, patch: { title }, }) return ok(`renamed to "${title}" (${convoId})`, [{ @@ -4677,7 +4686,7 @@ async function cmdCalendar(parsed: ParsedArgs, internal: RunCliInternalContext = if (!id) return err('usage: calendar run-now ') // Privacy gate: only people who can see the row can dispatch it. const { rows } = await pool.query( - `SELECT id, company_id, created_by, kind, title, description, assignee_id, + `SELECT id,company_id,project_id,created_by,kind,title,description,assignee_id, target_conversation_id, agent_prompt, start_at, end_at, all_day, recurrence, status, last_fired_at, reminder_minutes_before, reminder_channel, diff --git a/server/src/api/router.ts b/server/src/api/router.ts index 6a15a195..18292209 100644 --- a/server/src/api/router.ts +++ b/server/src/api/router.ts @@ -32,6 +32,7 @@ import { import { pool } from '../db/pool.js' import { env } from '../env.js' import { imRouter } from '../im/router.js' +import { wukongClient } from '../im/wukong.js' import { type InvitationEmailDelivery, sendInvitationEmail } from '../invitation-email.js' import { parseMentions as parseChatMentions } from '../mentions.js' import { @@ -48,7 +49,7 @@ import { OgError, ogPreview } from '../og.js' import { onboardStarterAgents, seedMemberDms } from '../onboardCompany.js' import { castVote, closePoll, createPoll, PollError } from '../polls.js' import { computeMessageRecipients, notifyMessage } from '../push.js' -import { CH_CALENDAR_EVENTS, CH_CONVO_UPDATED, CH_DOCS, CH_MESSAGE_NEW, CH_REACTIONS, CH_TYPING, publish, redis } from '../redis.js' +import { CH_CALENDAR_EVENTS, CH_CONVO_UPDATED, CH_DOC_ACCESS_REVOKED, CH_DOCS, CH_MESSAGE_NEW, CH_REACTIONS, CH_TYPING, publish, redis } from '../redis.js' import { BUSY_STATUS_LEASE_MS } from '../status.js' import { freshenAttachmentUrl, normalizeStorageKey, storage, storageKeyFromPublicUrl, UPLOAD_DIR } from '../storage.js' import { getUserQuota, sub2apiConfigured } from '../sub2api.js' @@ -284,27 +285,78 @@ async function companyArtifactBucket(companyId: string): Promise { return rows[0].id } -async function requireCompanyArtifactContext(req: Request & AuthedRequest) { +async function requireCompanyArtifactContext(req: Request & AuthedRequest, writable = false) { + const header = typeof req.headers['x-project-id'] === 'string' ? req.headers['x-project-id'].trim() : '' + if (header) { + const workspace = await requireWorkspace(req, header) + if (writable && workspace.projectStatus !== 'active') throw new HttpError(409, 'archived courses are read-only') + return { userId: workspace.userId, companyId: workspace.companyId, projectId: workspace.projectId } + } const company = await requireCompany(req) return { ...company, projectId: await companyArtifactBucket(company.companyId) } } +async function assertProjectWritable(projectId: string | null): Promise { + if (!projectId) return + const { rows } = await pool.query<{ status: string }>(`SELECT status FROM projects WHERE id=$1 LIMIT 1`, [projectId]) + if (!rows[0] || rows[0].status !== 'active') throw new HttpError(409, 'archived courses are read-only') +} + +async function assertConversationWritable(companyId: string, conversationId: string): Promise { + const { rows } = await pool.query<{ project_id: string | null }>( + `SELECT project_id FROM conversations WHERE id=$1 AND company_id=$2`, [conversationId, companyId], + ) + if (!rows[0]) throw new HttpError(404, 'not found') + await assertProjectWritable(rows[0].project_id) +} + +async function assertPollConversationWritable(companyId: string, messageId: string): Promise { + const { rows } = await pool.query<{ channel_id: string }>( + `SELECT channel_id FROM im_polls WHERE poll_client_msg_no=$1 AND company_id=$2`, + [messageId, companyId], + ) + if (!rows[0]) throw new HttpError(404, 'poll not found') + await assertConversationWritable(companyId, rows[0].channel_id) +} + async function requireWorkspace( req: Request & AuthedRequest, explicitProjectId?: string, -): Promise<{ userId: string; companyId: string; projectId: string; role: string; projectCreatedBy: string; isGeneral: boolean }> { +): Promise<{ + userId: string; companyId: string; projectId: string; role: string + projectCreatedBy: string; isGeneral: boolean; projectStatus: string + courseId: string | null; courseRole: 'teacher' | 'learner' | null +}> { const { userId, companyId } = await requireCompany(req) const header = typeof req.headers['x-project-id'] === 'string' ? req.headers['x-project-id'].trim() : '' const projectId = explicitProjectId?.trim() || header if (!projectId) throw new HttpError(400, 'x-project-id is required inside a knowledge workspace') if (explicitProjectId && header && header !== explicitProjectId) throw new HttpError(409, 'workspace header does not match route') - const { rows } = await pool.query<{ created_by: string; is_general: boolean; role: string }>( - `SELECT p.created_by, p.is_general, cm.role + const { rows } = await pool.query<{ + created_by: string; is_general: boolean; status: string; role: string + course_id: string | null; course_role: 'teacher' | 'learner' | null + }>( + `SELECT p.created_by, p.is_general, p.status, cm.role, + course.id AS course_id, course_member.role AS course_role FROM projects p JOIN company_members cm ON cm.company_id = p.company_id AND cm.user_id = $2 + LEFT JOIN courses course ON course.project_id = p.id AND course.company_id = p.company_id + LEFT JOIN course_members course_member + ON course_member.course_id = course.id AND course_member.user_id = $2 WHERE p.id = $1 AND p.company_id = $3 LIMIT 1`, [projectId, userId, companyId], ) - if (!rows[0]) throw new HttpError(404, 'workspace not found') - return { userId, companyId, projectId, role: rows[0].role, projectCreatedBy: rows[0].created_by, isGeneral: rows[0].is_general } + const row = rows[0] + if (!row || ( + !row.is_general + && !PRIVILEGED_ROLES.has(row.role) + && (!row.course_id || !row.course_role) + )) { + throw new HttpError(404, 'workspace not found') + } + return { + userId, companyId, projectId, role: row.role, + projectCreatedBy: row.created_by, isGeneral: row.is_general, + projectStatus: row.status, courseId: row.course_id, courseRole: row.course_role, + } } const DEVTOOLS_HEADER = 'x-lingxiloop-dev-mode' @@ -369,11 +421,22 @@ async function requireConversationMember( conversationId: string, ): Promise<{ userId: string; companyId: string; projectId: string | null; members: string[]; kind: string }> { const { userId, companyId } = await requireCompany(req) - const { rows } = await pool.query<{ project_id: string | null; members: string[]; kind: string }>( - `SELECT project_id, members, kind FROM conversations WHERE id = $1 AND company_id = $2 LIMIT 1`, - [conversationId, companyId], - ) - if (!rows[0]) throw new HttpError(404, 'not found') + const { rows } = await pool.query<{ project_id: string | null; members: string[]; kind: string; project_allowed: boolean }>( + `SELECT conversation.project_id,conversation.members,conversation.kind, + (conversation.project_id IS NULL OR project.is_general=TRUE + OR company_member.role IN ('owner','admin') + OR course_member.user_id IS NOT NULL) AS project_allowed + FROM conversations conversation + LEFT JOIN projects project ON project.id=conversation.project_id + JOIN company_members company_member + ON company_member.company_id=conversation.company_id AND company_member.user_id=$3 + LEFT JOIN courses course ON course.project_id=project.id + LEFT JOIN course_members course_member + ON course_member.course_id=course.id AND course_member.user_id=$3 + WHERE conversation.id=$1 AND conversation.company_id=$2 LIMIT 1`, + [conversationId, companyId, userId], + ) + if (!rows[0] || !rows[0].project_allowed) throw new HttpError(404, 'not found') if (!rows[0].members.includes(userId)) { // Stay opaque: same 404 a non-existent / cross-tenant convo returns, // so a probing client can't tell "doesn't exist" from "I'm not in it". @@ -389,26 +452,40 @@ async function requireGroupConversation(req: Request & AuthedRequest, conversati return { ...membership, role: rows[0]?.role ?? 'member' } } -async function requireCanvasWorkspace(req: Request & AuthedRequest, canvasId: string) { +async function requireCanvasWorkspace(req: Request & AuthedRequest, canvasId: string, writable = false) { const { userId, companyId } = await requireCompany(req) - const { rows } = await pool.query<{ conversation_id: string }>( - `SELECT cv.conversation_id FROM canvases cv JOIN conversations c ON c.id=cv.conversation_id - WHERE cv.id=$1 AND cv.company_id=$2 AND c.kind='group' AND c.members @> to_jsonb(ARRAY[$3::text]) LIMIT 1`, + const { rows } = await pool.query<{ conversation_id: string; project_id: string | null }>( + `SELECT cv.conversation_id,c.project_id FROM canvases cv JOIN conversations c ON c.id=cv.conversation_id + JOIN projects project ON project.id=c.project_id + JOIN company_members company_member ON company_member.company_id=c.company_id AND company_member.user_id=$3 + LEFT JOIN courses course ON course.project_id=project.id + LEFT JOIN course_members course_member ON course_member.course_id=course.id AND course_member.user_id=$3 + WHERE cv.id=$1 AND cv.company_id=$2 AND c.kind='group' AND c.members @> to_jsonb(ARRAY[$3::text]) + AND (project.is_general=TRUE OR company_member.role IN ('owner','admin') OR course_member.user_id IS NOT NULL) + LIMIT 1`, [canvasId, companyId, userId], ) if (!rows[0]) throw new HttpError(404, 'canvas not found') - return { userId, companyId, projectId: null, conversationId: rows[0].conversation_id } + if (writable) await assertProjectWritable(rows[0].project_id) + return { userId, companyId, projectId: rows[0].project_id, conversationId: rows[0].conversation_id } } -async function requireCanvasFrameWorkspace(req: Request & AuthedRequest, frameId: string) { +async function requireCanvasFrameWorkspace(req: Request & AuthedRequest, frameId: string, writable = false) { const { userId, companyId } = await requireCompany(req) - const { rows } = await pool.query( - `SELECT 1 FROM canvas_frames f JOIN canvases cv ON cv.id=f.canvas_id JOIN conversations c ON c.id=cv.conversation_id - WHERE f.id=$1 AND cv.company_id=$2 AND c.kind='group' AND c.members @> to_jsonb(ARRAY[$3::text]) LIMIT 1`, + const { rows } = await pool.query<{ project_id: string | null }>( + `SELECT c.project_id FROM canvas_frames f JOIN canvases cv ON cv.id=f.canvas_id JOIN conversations c ON c.id=cv.conversation_id + JOIN projects project ON project.id=c.project_id + JOIN company_members company_member ON company_member.company_id=c.company_id AND company_member.user_id=$3 + LEFT JOIN courses course ON course.project_id=project.id + LEFT JOIN course_members course_member ON course_member.course_id=course.id AND course_member.user_id=$3 + WHERE f.id=$1 AND cv.company_id=$2 AND c.kind='group' AND c.members @> to_jsonb(ARRAY[$3::text]) + AND (project.is_general=TRUE OR company_member.role IN ('owner','admin') OR course_member.user_id IS NOT NULL) + LIMIT 1`, [frameId, companyId, userId], ) if (!rows[0]) throw new HttpError(404, 'canvas frame not found') - return { userId, companyId, projectId: null } + if (writable) await assertProjectWritable(rows[0].project_id) + return { userId, companyId, projectId: rows[0].project_id } } async function getDevtoolsState(req: Request & AuthedRequest): Promise<{ @@ -788,7 +865,8 @@ api.get('/auth/start/:provider', safe(async (req, res) => { // auto-creating a personal workspace for net-new users. const inviteRaw = typeof req.query.invite === 'string' ? req.query.invite : '' const inviteToken = inviteRaw && inviteRaw.length >= 8 && inviteRaw.length <= 200 ? inviteRaw : null - const state = await createState(provider, returnUrl, inviteToken) + const inviteKind = req.query.inviteKind === 'course' ? 'course' : inviteToken ? 'company' : null + const state = await createState(provider, returnUrl, inviteToken, inviteKind) res.redirect(await authorizeUrl(provider, state)) })) @@ -1115,6 +1193,162 @@ api.post('/companies', async (req, res) => { res.status(500).json({ error: 'failed to create company after retries' }) }) +api.get('/companies/:id', safe(async (req, res) => { + const companyId = String(req.params.id) + const me = requireAuth(req) + const { rows } = await pool.query<{ + id: string; name: string; slug: string; description: string; role: string; created_at: string + }>( + `SELECT c.id, c.name, c.slug, c.description, cm.role, c.created_at + FROM companies c JOIN company_members cm ON cm.company_id=c.id + WHERE c.id=$1 AND cm.user_id=$2`, + [companyId, me], + ) + if (!rows[0]) throw new HttpError(404, 'company not found') + res.json({ + id: rows[0].id, name: rows[0].name, slug: rows[0].slug, + description: rows[0].description, role: rows[0].role, createdAt: rows[0].created_at, + }) +})) + +api.patch('/companies/:id', safe(async (req, res) => { + const companyId = String(req.params.id) + const { userId: me } = await requireCompanyAdmin(req, companyId) + const name = typeof req.body?.name === 'string' ? req.body.name.trim().slice(0, 80) : null + const description = typeof req.body?.description === 'string' ? req.body.description.trim().slice(0, 1000) : null + if (name === null && description === null) throw new HttpError(400, 'nothing to update') + if (name !== null && !name) throw new HttpError(400, 'name required') + await pool.query( + `UPDATE companies SET + name=COALESCE($2,name), description=COALESCE($3,description), updated_at=NOW() + WHERE id=$1`, + [companyId, name, description], + ) + await audit({ kind: 'company_update', userId: me, companyId, detail: { name, description } }) + const { rows } = await pool.query<{ id: string; name: string; slug: string; description: string }>( + `SELECT id,name,slug,description FROM companies WHERE id=$1`, [companyId], + ) + res.json(rows[0]) +})) + +api.get('/companies/:id/members', safe(async (req, res) => { + const companyId = String(req.params.id) + await requireCompanyAdmin(req, companyId) + const { rows } = await pool.query( + `SELECT u.id, u.display_name AS name, u.email, cm.role, + cm.joined_at AS "joinedAt", + COALESCE(jsonb_agg(jsonb_build_object( + 'courseId', course.id, 'name', project.name, 'role', course_member.role + )) FILTER (WHERE course.id IS NOT NULL), '[]'::jsonb) AS courses + FROM company_members cm + JOIN users u ON u.id=cm.user_id + LEFT JOIN course_members course_member + ON course_member.company_id=cm.company_id AND course_member.user_id=cm.user_id + LEFT JOIN courses course ON course.id=course_member.course_id + LEFT JOIN projects project ON project.id=course.project_id + WHERE cm.company_id=$1 + GROUP BY u.id,u.display_name,u.email,cm.role,cm.joined_at + ORDER BY CASE cm.role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END, cm.joined_at`, + [companyId], + ) + res.json(rows) +})) + +api.patch('/companies/:id/members/:userId', safe(async (req, res) => { + const companyId = String(req.params.id) + const targetId = String(req.params.userId) + const { userId: me } = await requireCompanyAdmin(req, companyId) + const role = String(req.body?.role ?? '') + if (role !== 'admin' && role !== 'member') throw new HttpError(400, 'role must be admin or member') + if (targetId === me) throw new HttpError(409, 'you cannot change your own company role') + const { rows } = await pool.query<{ role: string }>( + `SELECT role FROM company_members WHERE company_id=$1 AND user_id=$2`, [companyId, targetId], + ) + if (!rows[0]) throw new HttpError(404, 'member not found') + if (rows[0].role === 'owner') throw new HttpError(409, 'the company owner cannot be demoted') + await pool.query(`UPDATE company_members SET role=$3 WHERE company_id=$1 AND user_id=$2`, [companyId, targetId, role]) + await audit({ kind: 'company_member_role_update', userId: me, companyId, detail: { targetId, role } }) + res.json({ ok: true, userId: targetId, role }) +})) + +api.delete('/companies/:id/members/:userId', safe(async (req, res) => { + const companyId = String(req.params.id) + const targetId = String(req.params.userId) + const { userId: me } = await requireCompanyAdmin(req, companyId) + if (targetId === me) throw new HttpError(409, 'you cannot remove yourself') + const client = await pool.connect() + try { + await client.query('BEGIN') + const { rows: members } = await client.query<{ role: string }>( + `SELECT role FROM company_members WHERE company_id=$1 AND user_id=$2 FOR UPDATE`, + [companyId, targetId], + ) + if (!members[0]) throw new HttpError(404, 'member not found') + if (members[0].role === 'owner') throw new HttpError(409, 'the company owner cannot be removed') + + // Company membership deletion cascades into every course membership. Lock + // all affected active Course rows in a stable order before counting, so + // concurrent removals of different teachers cannot both validate against + // the same pre-delete teacher count. + const { rows: teachingCourses } = await client.query<{ id: string; name: string }>( + `SELECT course.id,project.name + FROM course_members member + JOIN courses course ON course.id=member.course_id + JOIN projects project ON project.id=course.project_id + WHERE member.company_id=$1 AND member.user_id=$2 AND member.role='teacher' + AND project.status='active' + ORDER BY course.id + FOR UPDATE OF course`, + [companyId, targetId], + ) + for (const course of teachingCourses) { + const { rows: teacherCount } = await client.query<{ count: number }>( + `SELECT COUNT(*)::int AS count FROM course_members WHERE course_id=$1 AND role='teacher'`, + [course.id], + ) + if ((teacherCount[0]?.count ?? 0) <= 1) { + throw new HttpError(409, `${course.name} must keep at least one teacher`) + } + } + await client.query(`DELETE FROM company_members WHERE company_id=$1 AND user_id=$2`, [companyId, targetId]) + await client.query( + `UPDATE participants SET departed_at=NOW(), status='offboarded' + WHERE company_id=$1 AND id=$2 AND kind='human'`, [companyId, targetId], + ) + await client.query( + `UPDATE conversations conversation + SET members=(SELECT COALESCE(jsonb_agg(value), '[]'::jsonb) + FROM jsonb_array_elements(conversation.members) value + WHERE value <> to_jsonb($2::text)), updated_at=NOW() + WHERE company_id=$1 AND members @> to_jsonb(ARRAY[$2::text])`, + [companyId, targetId], + ) + await client.query( + `UPDATE im_channel_bindings binding + SET profile=jsonb_set(binding.profile, '{members}', conversation.members, TRUE) + FROM conversations conversation + WHERE binding.channel_id=conversation.id AND binding.company_id=$1`, [companyId], + ) + await client.query('COMMIT') + } catch (error) { + await client.query('ROLLBACK').catch(() => undefined) + throw error + } finally { client.release() } + const { rows: channels } = await pool.query<{ channel_id: string; title: string; members: string[] }>( + `SELECT binding.channel_id, COALESCE(binding.profile->>'title',binding.channel_id) AS title, + conversation.members + FROM im_channel_bindings binding JOIN conversations conversation ON conversation.id=binding.channel_id + WHERE binding.company_id=$1`, [companyId], + ) + for (const channel of channels) { + void wukongClient().upsertChannel({ channelId: channel.channel_id, channelType: 2, title: channel.title, members: channel.members }).catch(() => undefined) + } + const { disconnectUserFromCompany } = await import('../ws.js') + disconnectUserFromCompany(targetId, companyId) + await audit({ kind: 'company_member_remove', userId: me, companyId, detail: { targetId } }) + res.json({ ok: true }) +})) + /* ============== Shared Canvas (shared state, isolated execution) ======= */ api.get('/conversations/:id/canvas', safe(async (req, res) => { @@ -1124,6 +1358,7 @@ api.get('/conversations/:id/canvas', safe(async (req, res) => { api.post('/conversations/:id/canvas', safe(async (req, res) => { const membership = await requireGroupConversation(req, String(req.params.id)) + await assertProjectWritable(membership.projectId) res.status(201).json(await ensureConversationCanvas(membership.companyId, String(req.params.id), membership.userId)) })) @@ -1149,7 +1384,7 @@ api.get('/canvases/:id', safe(async (req, res) => { })) api.post('/canvases/:id/assignments', safe(async (req, res) => { - const { userId, companyId } = await requireCanvasWorkspace(req, String(req.params.id)) + const { userId, companyId } = await requireCanvasWorkspace(req, String(req.params.id), true) res.status(201).json(await assignCanvasWorkspaceWork({ companyId, canvasId: String(req.params.id), @@ -1160,19 +1395,19 @@ api.post('/canvases/:id/assignments', safe(async (req, res) => { })) api.post('/canvases/:id/assignments/:agentId/steer', safe(async (req, res) => { - const { companyId } = await requireCanvasWorkspace(req, String(req.params.id)) + const { companyId } = await requireCanvasWorkspace(req, String(req.params.id), true) await steerCanvasAssignment({ companyId, canvasId: String(req.params.id), agentId: String(req.params.agentId), text: String(req.body?.text ?? '') }) res.json({ ok: true }) })) api.post('/canvases/:id/assignments/:agentId/stop', safe(async (req, res) => { - const { companyId } = await requireCanvasWorkspace(req, String(req.params.id)) + const { companyId } = await requireCanvasWorkspace(req, String(req.params.id), true) await stopCanvasAssignment({ companyId, canvasId: String(req.params.id), agentId: String(req.params.agentId) }) res.json({ ok: true }) })) api.post('/canvases/:id/stop', safe(async (req, res) => { - const { companyId } = await requireCanvasWorkspace(req, String(req.params.id)) + const { companyId } = await requireCanvasWorkspace(req, String(req.params.id), true) await stopCanvasWorkspace({ companyId, canvasId: String(req.params.id) }) res.json({ ok: true }) })) @@ -1180,14 +1415,14 @@ api.post('/canvases/:id/stop', safe(async (req, res) => { api.post('/canvas/frames', safe(async (req, res) => { const requestedCanvasId = typeof req.body?.canvasId === 'string' ? req.body.canvasId : undefined if (!requestedCanvasId) throw new HttpError(400, 'canvasId is required') - const { userId, companyId, projectId } = await requireCanvasWorkspace(req, requestedCanvasId) + const { userId, companyId, projectId } = await requireCanvasWorkspace(req, requestedCanvasId, true) res.status(201).json(await createCanvasFrame({ companyId, projectId: projectId ?? undefined, actorId: userId, actorKind: 'user', canvasId: requestedCanvasId, frame: req.body ?? {}, })) })) api.patch('/canvas/frames/:id', safe(async (req, res) => { - const { userId, companyId } = await requireCanvasFrameWorkspace(req, String(req.params.id)) + const { userId, companyId } = await requireCanvasFrameWorkspace(req, String(req.params.id), true) try { res.json(await updateCanvasFrame({ companyId, actorId: userId, actorKind: 'user', frameId: String(req.params.id), patch: req.body ?? {}, @@ -1200,7 +1435,7 @@ api.patch('/canvas/frames/:id', safe(async (req, res) => { })) api.post('/canvas/frames/:id/append', safe(async (req, res) => { - const { userId, companyId } = await requireCanvasFrameWorkspace(req, String(req.params.id)) + const { userId, companyId } = await requireCanvasFrameWorkspace(req, String(req.params.id), true) res.json(await appendCanvasFrameContent({ companyId, actorId: userId, actorKind: 'user', frameId: String(req.params.id), content: String(req.body?.content ?? ''), @@ -1208,7 +1443,7 @@ api.post('/canvas/frames/:id/append', safe(async (req, res) => { })) api.delete('/canvas/frames/:id', safe(async (req, res) => { - const { userId, companyId } = await requireCanvasFrameWorkspace(req, String(req.params.id)) + const { userId, companyId } = await requireCanvasFrameWorkspace(req, String(req.params.id), true) res.json(await deleteCanvasFrame({ companyId, actorId: userId, actorKind: 'user', frameId: String(req.params.id), })) @@ -1217,7 +1452,7 @@ api.delete('/canvas/frames/:id', safe(async (req, res) => { api.post('/canvas/status', safe(async (req, res) => { const requestedCanvasId = typeof req.body?.canvasId === 'string' ? req.body.canvasId : undefined if (!requestedCanvasId) throw new HttpError(400, 'canvasId is required') - const { userId, companyId, projectId } = await requireCanvasWorkspace(req, requestedCanvasId) + const { userId, companyId, projectId } = await requireCanvasWorkspace(req, requestedCanvasId, true) res.json(await setCanvasStatus({ companyId, projectId: projectId ?? undefined, actorId: userId, actorKind: 'user', canvasId: requestedCanvasId, status: String(req.body?.status ?? ''), @@ -1230,7 +1465,7 @@ api.post('/canvas/status', safe(async (req, res) => { api.post('/canvas/comments', safe(async (req, res) => { const requestedCanvasId = typeof req.body?.canvasId === 'string' ? req.body.canvasId : undefined if (!requestedCanvasId) throw new HttpError(400, 'canvasId is required') - const { userId, companyId, projectId } = await requireCanvasWorkspace(req, requestedCanvasId) + const { userId, companyId, projectId } = await requireCanvasWorkspace(req, requestedCanvasId, true) res.status(201).json(await addCanvasComment({ companyId, projectId: projectId ?? undefined, actorId: userId, actorKind: 'user', canvasId: requestedCanvasId, frameId: typeof req.body?.frameId === 'string' ? req.body.frameId : null, @@ -1349,6 +1584,104 @@ async function requireCompanyAdmin(req: Request & AuthedRequest, companyId: stri return { userId: me, role: rows[0].role } } +async function requireCourseManager( + req: Request & AuthedRequest, + courseId: string, +): Promise<{ userId: string; companyId: string; companyRole: string; courseRole: string | null; projectId: string; status: string }> { + const me = requireAuth(req) + const { rows } = await pool.query<{ + company_id: string; company_role: string; course_role: string | null; project_id: string; status: string + }>( + `SELECT course.company_id, company_member.role AS company_role, + course_member.role AS course_role, course.project_id, project.status + FROM courses course + JOIN projects project ON project.id=course.project_id + JOIN company_members company_member + ON company_member.company_id=course.company_id AND company_member.user_id=$2 + LEFT JOIN course_members course_member + ON course_member.course_id=course.id AND course_member.user_id=$2 + WHERE course.id=$1`, + [courseId, me], + ) + const row = rows[0] + if (!row) throw new HttpError(404, 'course not found') + if (!PRIVILEGED_ROLES.has(row.company_role) && row.course_role !== 'teacher') { + throw new HttpError(403, 'this action requires a course teacher or company admin') + } + return { + userId: me, companyId: row.company_id, companyRole: row.company_role, + courseRole: row.course_role, projectId: row.project_id, status: row.status, + } +} + +async function assertCanCreateCourse(userId: string, companyId: string): Promise { + const { rows } = await pool.query<{ company_role: string; is_teacher: boolean }>( + `SELECT company_member.role AS company_role, + EXISTS ( + SELECT 1 FROM course_members course_member + JOIN courses course ON course.id=course_member.course_id + JOIN projects project ON project.id=course.project_id + WHERE course_member.company_id=$1 AND course_member.user_id=$2 + AND course_member.role='teacher' AND project.status='active' + ) AS is_teacher + FROM company_members company_member + WHERE company_member.company_id=$1 AND company_member.user_id=$2`, + [companyId, userId], + ) + if (!rows[0]) throw new HttpError(403, 'not a member of this company') + if (!PRIVILEGED_ROLES.has(rows[0].company_role) && !rows[0].is_teacher) { + throw new HttpError(403, 'only a company admin or existing teacher can create courses') + } +} + +async function syncCourseStudyRoom(courseId: string): Promise { + const { rows } = await pool.query<{ + room_id: string | null; company_id: string; title: string; topic: string | null; leader_id: string | null + }>( + `SELECT course.study_room_conversation_id AS room_id, course.company_id, + conversation.title, conversation.topic, conversation.leader_id + FROM courses course + LEFT JOIN conversations conversation ON conversation.id=course.study_room_conversation_id + WHERE course.id=$1`, [courseId], + ) + const course = rows[0] + if (!course?.room_id) return + const { rows: members } = await pool.query<{ id: string }>( + `SELECT course_member.user_id AS id FROM course_members course_member WHERE course_member.course_id=$1 + UNION + SELECT participant.id FROM participants participant + WHERE participant.company_id=$2 AND participant.kind='agent' + AND participant.preset_key IN ('nova','sage','milo','trace') + AND participant.departed_at IS NULL`, + [courseId, course.company_id], + ) + const memberIds = members.map((member) => member.id) + await pool.query( + `UPDATE conversations SET members=$2::jsonb, subtitle=$3, updated_at=NOW() + WHERE id=$1 AND company_id=$4`, + [course.room_id, JSON.stringify(memberIds), `course · ${memberIds.length}`, course.company_id], + ) + const profile = { + channelId: course.room_id, channelType: 2, kind: 'group', title: course.title, + topic: course.topic, members: memberIds, pinned: true, createdAt: new Date().toISOString(), + } + await pool.query( + `INSERT INTO im_channel_bindings (channel_id,company_id,profile,leader_agent_id) + VALUES ($1,$2,$3::jsonb,$4) + ON CONFLICT (channel_id) DO UPDATE SET profile=EXCLUDED.profile,leader_agent_id=EXCLUDED.leader_agent_id`, + [course.room_id, course.company_id, JSON.stringify(profile), course.leader_id], + ) + await wukongClient().upsertChannel({ + channelId: course.room_id, channelType: 2, title: course.title, + members: memberIds, ...(course.leader_id ? { leaderAgentId: course.leader_id } : {}), + }).catch((error) => console.warn('[course] Study Room sync failed', error)) +} + +function buildCourseInviteUrl(token: string): string { + const base = (env.INVITE_BASE_URL || env.AUTH_DONE_URL).replace(/\/+$/, '') + return `${base}/invite/course/${encodeURIComponent(token)}` +} + /** Build the public-facing accept URL for an invite — always an https web * origin (e.g. https://loop.example.com/invite/). The web bundle hosted * there has its API origin baked in at build time (VITE_LINGXILOOP_API_BASE), so @@ -1758,6 +2091,511 @@ api.post('/invitations/:token/accept', safe(async (req, res) => { }) })) +/* ============== Courses ============== */ + +api.get('/courses', safe(async (req, res) => { + const { userId: me, companyId } = await requireCompany(req) + const { rows } = await pool.query( + `SELECT course.id, course.company_id AS "companyId", course.created_by AS "createdBy", + course.study_room_conversation_id AS "studyRoomId", course.created_at AS "createdAt", + project.id AS "projectId", project.name, project.description, project.color, + project.status, project.created_at AS "projectCreatedAt", project.updated_at AS "updatedAt", + company_member.role AS "companyRole", course_member.role AS "courseRole", + (SELECT COUNT(*)::int FROM course_members member WHERE member.course_id=course.id) AS "memberCount", + (company_member.role IN ('owner','admin') OR course_member.role='teacher') AS "canManage" + FROM courses course + JOIN projects project ON project.id=course.project_id + JOIN company_members company_member + ON company_member.company_id=course.company_id AND company_member.user_id=$2 + LEFT JOIN course_members course_member + ON course_member.course_id=course.id AND course_member.user_id=$2 + WHERE course.company_id=$1 + AND (company_member.role IN ('owner','admin') OR course_member.user_id IS NOT NULL) + ORDER BY project.status ASC, project.updated_at DESC`, + [companyId, me], + ) + res.json(rows) +})) + +api.post('/courses', safe(async (req, res) => { + const { userId: me, companyId } = await requireCompany(req) + await assertCanCreateCourse(me, companyId) + const name = String(req.body?.name ?? '').trim().slice(0, 80) + const description = String(req.body?.description ?? '').trim().slice(0, 1000) + const color = req.body?.color == null ? '#5266d6' : String(req.body.color).slice(0, 200) + if (!name) throw new HttpError(400, 'name required') + const projectId = `p-${randomUUID().slice(0, 10)}` + const courseId = `course-${randomUUID().slice(0, 12)}` + const roomId = `course-room-${randomUUID().slice(0, 12)}` + const client = await pool.connect() + try { + await client.query('BEGIN') + await client.query( + `INSERT INTO projects (id,company_id,name,description,color,created_by,is_general) + VALUES ($1,$2,$3,$4,$5,$6,FALSE)`, + [projectId, companyId, name, description, color, me], + ) + await client.query( + `INSERT INTO courses (id,company_id,project_id,created_by) VALUES ($1,$2,$3,$4)`, + [courseId, companyId, projectId, me], + ) + await client.query( + `INSERT INTO course_members (course_id,company_id,user_id,role) VALUES ($1,$2,$3,'teacher')`, + [courseId, companyId, me], + ) + const { rows: agents } = await client.query<{ id: string; preset_key: string }>( + `SELECT id,preset_key FROM participants + WHERE company_id=$1 AND kind='agent' AND preset_key IN ('nova','sage','milo','trace') + AND departed_at IS NULL`, [companyId], + ) + const memberIds = [me, ...agents.map((agent) => agent.id)] + const leaderId = agents.find((agent) => agent.preset_key === 'nova')?.id ?? agents[0]?.id ?? null + await client.query( + `INSERT INTO conversations + (id,kind,title,subtitle,topic,members,leader_id,pinned,tag,company_id,project_id) + VALUES ($1,'group',$2,$3,$4,$5::jsonb,$6,TRUE,'course',$7,$8)`, + [roomId, `${name} · Study Room`, `course · ${memberIds.length}`, + '课程学习、讨论、练习与错因诊断', JSON.stringify(memberIds), leaderId, companyId, projectId], + ) + await client.query(`INSERT INTO conversation_counters (conversation_id,next_sequence) VALUES ($1,1)`, [roomId]) + await client.query(`UPDATE courses SET study_room_conversation_id=$2 WHERE id=$1`, [courseId, roomId]) + await client.query('COMMIT') + } catch (error) { + await client.query('ROLLBACK').catch(() => undefined) + throw error + } finally { client.release() } + await syncCourseStudyRoom(courseId) + let knowledgeState: 'disabled' | 'ready' | 'failed' = openNotebookEnabled() ? 'ready' : 'disabled' + if (openNotebookEnabled()) { + try { await ensureProjectNotebook(projectId, companyId) } + catch (error) { knowledgeState = 'failed'; console.warn('[course] notebook provisioning failed', error) } + } + await audit({ kind: 'course_create', userId: me, companyId, detail: { courseId, projectId, name } }) + res.status(201).json({ + id: courseId, companyId, projectId, name, description, color, status: 'active', + createdBy: me, studyRoomId: roomId, courseRole: 'teacher', memberCount: 1, + canManage: true, knowledgeState, + }) +})) + +api.get('/courses/:id', safe(async (req, res) => { + const courseId = String(req.params.id) + const { userId: me, companyId } = await requireCompany(req) + const { rows } = await pool.query( + `SELECT course.id,course.company_id AS "companyId",course.project_id AS "projectId", + course.created_by AS "createdBy",course.study_room_conversation_id AS "studyRoomId", + project.name,project.description,project.color,project.status, + company_member.role AS "companyRole",course_member.role AS "courseRole", + (SELECT COUNT(*)::int FROM course_members member WHERE member.course_id=course.id) AS "memberCount", + (company_member.role IN ('owner','admin') OR course_member.role='teacher') AS "canManage" + FROM courses course JOIN projects project ON project.id=course.project_id + JOIN company_members company_member ON company_member.company_id=course.company_id AND company_member.user_id=$3 + LEFT JOIN course_members course_member ON course_member.course_id=course.id AND course_member.user_id=$3 + WHERE course.id=$1 AND course.company_id=$2 + AND (company_member.role IN ('owner','admin') OR course_member.user_id IS NOT NULL)`, + [courseId, companyId, me], + ) + if (!rows[0]) throw new HttpError(404, 'course not found') + res.json(rows[0]) +})) + +api.patch('/courses/:id', safe(async (req, res) => { + const courseId = String(req.params.id) + const manager = await requireCourseManager(req, courseId) + if (manager.status !== 'active') throw new HttpError(409, 'archived courses are read-only') + const name = typeof req.body?.name === 'string' ? req.body.name.trim().slice(0, 80) : null + const description = typeof req.body?.description === 'string' ? req.body.description.trim().slice(0, 1000) : null + const color = typeof req.body?.color === 'string' ? req.body.color.slice(0, 200) : null + if (name === null && description === null && color === null) throw new HttpError(400, 'nothing to update') + if (name !== null && !name) throw new HttpError(400, 'name required') + await pool.query( + `UPDATE projects SET name=COALESCE($2,name),description=COALESCE($3,description), + color=COALESCE($4,color),updated_at=NOW() WHERE id=$1`, + [manager.projectId, name, description, color], + ) + if (name) { + await pool.query( + `UPDATE conversations SET title=$2,updated_at=NOW() + WHERE id=(SELECT study_room_conversation_id FROM courses WHERE id=$1)`, + [courseId, `${name} · Study Room`], + ) + await syncCourseStudyRoom(courseId) + } + await syncProjectNotebookMetadata(manager.projectId).catch(() => undefined) + await audit({ kind: 'course_update', userId: manager.userId, companyId: manager.companyId, detail: { courseId, name, description, color } }) + res.json({ ok: true }) +})) + +api.post('/courses/:id/archive', safe(async (req, res) => { + const courseId = String(req.params.id) + const manager = await requireCourseManager(req, courseId) + const archive = req.body?.archive !== false + await pool.query( + archive + ? `UPDATE projects SET status='archived',archived_at=NOW(),updated_at=NOW() WHERE id=$1` + : `UPDATE projects SET status='active',archived_at=NULL,updated_at=NOW() WHERE id=$1`, + [manager.projectId], + ) + await syncProjectNotebookMetadata(manager.projectId).catch(() => undefined) + await audit({ kind: archive ? 'course_archive' : 'course_unarchive', userId: manager.userId, companyId: manager.companyId, detail: { courseId } }) + res.json({ ok: true, status: archive ? 'archived' : 'active' }) +})) + +api.get('/courses/:id/members', safe(async (req, res) => { + const courseId = String(req.params.id) + await requireCourseManager(req, courseId) + const { rows } = await pool.query( + `SELECT users.id,users.display_name AS name,users.email,course_member.role, + course_member.joined_at AS "joinedAt" + FROM course_members course_member JOIN users ON users.id=course_member.user_id + WHERE course_member.course_id=$1 + ORDER BY CASE course_member.role WHEN 'teacher' THEN 0 ELSE 1 END, course_member.joined_at`, + [courseId], + ) + res.json(rows) +})) + +api.patch('/courses/:id/members/:userId', safe(async (req, res) => { + const courseId = String(req.params.id) + const targetId = String(req.params.userId) + const manager = await requireCourseManager(req, courseId) + if (manager.status !== 'active') throw new HttpError(409, 'archived courses are read-only') + const role = String(req.body?.role ?? '') + if (role !== 'teacher' && role !== 'learner') throw new HttpError(400, 'role must be teacher or learner') + const client = await pool.connect() + try { + await client.query('BEGIN') + // Serialize teacher removals per course. Without the course-row lock, two + // admins could concurrently demote the last two teachers after each sees + // the other one, leaving the active course unmanaged. + await client.query(`SELECT 1 FROM courses WHERE id=$1 FOR UPDATE`, [courseId]) + const { rows: member } = await client.query<{ role: string }>( + `SELECT role FROM course_members WHERE course_id=$1 AND user_id=$2`, [courseId, targetId], + ) + if (!member[0]) throw new HttpError(404, 'course member not found') + if (member[0].role === 'teacher' && role === 'learner') { + const { rows: count } = await client.query<{ count: number }>( + `SELECT COUNT(*)::int AS count FROM course_members WHERE course_id=$1 AND role='teacher'`, [courseId], + ) + if ((count[0]?.count ?? 0) <= 1) throw new HttpError(409, 'an active course must keep at least one teacher') + } + await client.query( + `UPDATE course_members SET role=$3,updated_at=NOW() WHERE course_id=$1 AND user_id=$2`, + [courseId, targetId, role], + ) + await client.query('COMMIT') + } catch (error) { + await client.query('ROLLBACK').catch(() => undefined) + throw error + } finally { client.release() } + await audit({ kind: 'course_member_role_update', userId: manager.userId, companyId: manager.companyId, detail: { courseId, targetId, role } }) + res.json({ ok: true, userId: targetId, role }) +})) + +api.delete('/courses/:id/members/:userId', safe(async (req, res) => { + const courseId = String(req.params.id) + const targetId = String(req.params.userId) + const manager = await requireCourseManager(req, courseId) + if (manager.status !== 'active') throw new HttpError(409, 'archived courses are read-only') + const client = await pool.connect() + try { + await client.query('BEGIN') + await client.query(`SELECT 1 FROM courses WHERE id=$1 FOR UPDATE`, [courseId]) + const { rows: member } = await client.query<{ role: string }>( + `SELECT role FROM course_members WHERE course_id=$1 AND user_id=$2`, [courseId, targetId], + ) + if (!member[0]) throw new HttpError(404, 'course member not found') + if (member[0].role === 'teacher') { + const { rows: count } = await client.query<{ count: number }>( + `SELECT COUNT(*)::int AS count FROM course_members WHERE course_id=$1 AND role='teacher'`, [courseId], + ) + if ((count[0]?.count ?? 0) <= 1) throw new HttpError(409, 'an active course must keep at least one teacher') + } + await client.query(`DELETE FROM course_members WHERE course_id=$1 AND user_id=$2`, [courseId, targetId]) + await client.query('COMMIT') + } catch (error) { + await client.query('ROLLBACK').catch(() => undefined) + throw error + } finally { client.release() } + const { rows: changedChannels } = await pool.query<{ id: string; title: string; members: string[] }>( + `UPDATE conversations conversation + SET members=(SELECT COALESCE(jsonb_agg(value), '[]'::jsonb) + FROM jsonb_array_elements(conversation.members) value + WHERE value <> to_jsonb($2::text)),updated_at=NOW() + WHERE conversation.project_id=$1 AND conversation.members @> to_jsonb(ARRAY[$2::text]) + RETURNING conversation.id,conversation.title,conversation.members`, + [manager.projectId, targetId], + ) + await pool.query( + `UPDATE im_channel_bindings binding + SET profile=jsonb_set(binding.profile,'{members}',conversation.members,TRUE),updated_at=NOW() + FROM conversations conversation + WHERE binding.channel_id=conversation.id AND conversation.project_id=$1`, [manager.projectId], + ) + for (const channel of changedChannels) { + void wukongClient().upsertChannel({ channelId: channel.id, channelType: 2, title: channel.title, members: channel.members }).catch(() => undefined) + } + const { revokeUserProjectDocumentSubscriptions } = await import('../ws.js') + await revokeUserProjectDocumentSubscriptions(targetId, manager.projectId) + await publish(CH_DOC_ACCESS_REVOKED, { + type: 'doc.access.revoked', companyId: manager.companyId, + workspaceId: manager.projectId, userId: targetId, + }) + await syncCourseStudyRoom(courseId) + await audit({ kind: 'course_member_remove', userId: manager.userId, companyId: manager.companyId, detail: { courseId, targetId } }) + res.json({ ok: true }) +})) + +api.get('/courses/:id/invitations', safe(async (req, res) => { + const courseId = String(req.params.id) + await requireCourseManager(req, courseId) + const { rows } = await pool.query( + `SELECT invitation.token_hash AS id,invitation.email,invitation.role,invitation.note, + invitation.max_uses AS "maxUses",invitation.use_count AS "useCount", + invitation.created_at AS "createdAt",invitation.expires_at AS "expiresAt", + invitation.revoked_at AS "revokedAt",invitation.last_accepted_at AS "lastAcceptedAt", + invitation.last_accepted_by AS "lastAcceptedBy",invitation.invited_by AS "invitedBy", + users.display_name AS "inviterName", + COALESCE(( + SELECT jsonb_agg(jsonb_build_object( + 'userId', recent.user_id, 'name', recent.display_name, + 'role', recent.role, 'acceptedAt', recent.accepted_at + ) ORDER BY recent.accepted_at DESC) + FROM ( + SELECT acceptance.user_id,accepted_user.display_name,acceptance.role,acceptance.accepted_at + FROM course_invitation_acceptances acceptance + LEFT JOIN users accepted_user ON accepted_user.id=acceptance.user_id + WHERE acceptance.token_hash=invitation.token_hash + ORDER BY acceptance.accepted_at DESC LIMIT 10 + ) recent + ), '[]'::jsonb) AS acceptances, + CASE WHEN invitation.revoked_at IS NOT NULL THEN 'revoked' + WHEN invitation.expires_at < NOW() THEN 'expired' + WHEN invitation.use_count >= invitation.max_uses THEN 'consumed' + ELSE 'active' END AS status + FROM course_invitations invitation LEFT JOIN users ON users.id=invitation.invited_by + WHERE invitation.course_id=$1 ORDER BY invitation.created_at DESC`, + [courseId], + ) + res.json(rows) +})) + +api.post('/courses/:id/invitations', safe(async (req, res) => { + const courseId = String(req.params.id) + const manager = await requireCourseManager(req, courseId) + if (manager.status !== 'active') throw new HttpError(409, 'archived courses cannot issue invitations') + const emailRaw = typeof req.body?.email === 'string' ? req.body.email.trim().toLowerCase() : '' + const email = emailRaw || null + if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new HttpError(400, 'invalid email') + const role = String(req.body?.role ?? '') + if (role !== 'teacher' && role !== 'learner') throw new HttpError(400, 'role must be teacher or learner') + const note = typeof req.body?.note === 'string' ? req.body.note.trim().slice(0, 280) || null : null + const expiresInDays = Number(req.body?.expiresInDays ?? 7) + const maxUses = Number(req.body?.maxUses ?? 1) + if (!Number.isInteger(expiresInDays) || expiresInDays < 1 || expiresInDays > 30) { + throw new HttpError(400, 'expiresInDays must be an integer between 1 and 30') + } + if (!Number.isInteger(maxUses) || maxUses < 1 || maxUses > 100) { + throw new HttpError(400, 'maxUses must be an integer between 1 and 100') + } + if (email) { + await pool.query( + `UPDATE course_invitations SET revoked_at=NOW() + WHERE course_id=$1 AND email=$2 AND revoked_at IS NULL AND expires_at>NOW() AND use_count { + const courseId = String(req.params.id) + const manager = await requireCourseManager(req, courseId) + const { rowCount } = await pool.query( + `UPDATE course_invitations SET revoked_at=NOW() + WHERE token_hash=$1 AND course_id=$2 AND revoked_at IS NULL`, + [String(req.params.inviteId), courseId], + ) + if ((rowCount ?? 0) > 0) await audit({ kind: 'course_invitation_revoke', userId: manager.userId, companyId: manager.companyId, detail: { courseId, inviteId: req.params.inviteId } }) + res.json({ ok: true, revoked: (rowCount ?? 0) > 0 }) +})) + +api.get('/course-invitations/:token', safe(async (req, res) => { + const tokenHash = hashInviteToken(String(req.params.token)) + const { rows } = await pool.query<{ + course_id: string; company_id: string; email: string | null; role: string; note: string | null + max_uses: number; use_count: number; expires_at: string; revoked_at: string | null + course_name: string; project_id: string; project_status: string; room_id: string | null + company_name: string; company_slug: string; inviter_name: string | null + }>( + `SELECT invitation.course_id,invitation.company_id,invitation.email,invitation.role,invitation.note, + invitation.max_uses,invitation.use_count,invitation.expires_at,invitation.revoked_at, + project.name AS course_name,project.id AS project_id,project.status AS project_status, + course.study_room_conversation_id AS room_id,company.name AS company_name, + company.slug AS company_slug,users.display_name AS inviter_name + FROM course_invitations invitation JOIN courses course ON course.id=invitation.course_id + JOIN projects project ON project.id=course.project_id JOIN companies company ON company.id=course.company_id + LEFT JOIN users ON users.id=invitation.invited_by WHERE invitation.token_hash=$1`, + [tokenHash], + ) + const invitation = rows[0] + if (!invitation) { res.json({ status: 'not_found', kind: 'course' }); return } + let status = invitation.revoked_at ? 'revoked' + : new Date(invitation.expires_at).getTime() < Date.now() ? 'expired' + : invitation.use_count >= invitation.max_uses ? 'consumed' + : invitation.project_status !== 'active' ? 'archived' : 'valid' + if (req.authUserId) { + const { rows: viewer } = await pool.query<{ email: string; role: string | null }>( + `SELECT users.email,course_member.role FROM users + LEFT JOIN course_members course_member ON course_member.course_id=$2 AND course_member.user_id=users.id + WHERE users.id=$1`, [req.authUserId, invitation.course_id], + ) + if (viewer[0]?.role && (viewer[0].role === 'teacher' || invitation.role === viewer[0].role)) status = 'already_member' + else if (invitation.email && viewer[0]?.email.toLowerCase() !== invitation.email) status = 'wrong_email' + } + res.json({ + kind: 'course', status, + invitation: { + role: invitation.role, email: invitation.email, note: invitation.note, + expiresAt: new Date(invitation.expires_at).toISOString(), inviterName: invitation.inviter_name, + company: { id: invitation.company_id, name: invitation.company_name, slug: invitation.company_slug }, + course: { id: invitation.course_id, name: invitation.course_name, projectId: invitation.project_id, studyRoomId: invitation.room_id }, + }, + }) +})) + +api.post('/course-invitations/:token/accept', safe(async (req, res) => { + const me = requireAuth(req) + const tokenHash = hashInviteToken(String(req.params.token)) + const { rows: userRows } = await pool.query<{ email: string; display_name: string; avatar_url: string | null; email_verified_at: string | null }>( + `SELECT email,display_name,avatar_url,email_verified_at FROM users WHERE id=$1`, [me], + ) + const user = userRows[0] + if (!user) throw new HttpError(401, 'session points to missing user') + if (!user.email_verified_at) throw new HttpError(403, 'a verified email is required to accept a course invitation') + const client = await pool.connect() + let result: { companyId: string; companyName: string; companySlug: string; companyRole: string; courseId: string; courseName: string; projectId: string; roomId: string | null; role: string; alreadyMember: boolean; joinedCompany: boolean } + try { + await client.query('BEGIN') + // Invitations have independent token rows, so locking only the invitation + // does not serialize two different links accepted by the same user. The + // stable user row prevents a learner invite racing a teacher invite from + // observing stale membership state (and also serializes auto-join). + await client.query(`SELECT 1 FROM users WHERE id=$1 FOR UPDATE`, [me]) + const { rows } = await client.query<{ + company_id: string; course_id: string; email: string | null; role: 'teacher' | 'learner' + max_uses: number; use_count: number; expires_at: string; revoked_at: string | null + project_id: string; project_status: string; room_id: string | null; course_name: string + company_name: string; company_slug: string + }>( + `SELECT invitation.company_id,invitation.course_id,invitation.email,invitation.role, + invitation.max_uses,invitation.use_count,invitation.expires_at,invitation.revoked_at, + course.project_id,project.status AS project_status,course.study_room_conversation_id AS room_id, + project.name AS course_name,company.name AS company_name,company.slug AS company_slug + FROM course_invitations invitation JOIN courses course ON course.id=invitation.course_id + JOIN projects project ON project.id=course.project_id JOIN companies company ON company.id=course.company_id + WHERE invitation.token_hash=$1 FOR UPDATE OF invitation`, + [tokenHash], + ) + const invitation = rows[0] + if (!invitation) throw new HttpError(404, 'invitation not found') + if (invitation.revoked_at) throw new HttpError(410, 'invitation revoked') + if (new Date(invitation.expires_at).getTime() < Date.now()) throw new HttpError(410, 'invitation expired') + if (invitation.project_status !== 'active') throw new HttpError(410, 'course archived') + if (invitation.email && invitation.email !== user.email.toLowerCase()) throw new HttpError(403, `this invitation is reserved for ${invitation.email}`) + const { rows: priorAcceptance } = await client.query( + `SELECT 1 FROM course_invitation_acceptances WHERE token_hash=$1 AND user_id=$2`, [tokenHash, me], + ) + const { rows: existingCompany } = await client.query<{ role: string }>( + `SELECT role FROM company_members WHERE company_id=$1 AND user_id=$2`, [invitation.company_id, me], + ) + const joinedCompany = !existingCompany[0] + if (joinedCompany) { + await assertUserCompanyLimit(me, client) + await assertCompanyHumanLimit(invitation.company_id, client) + await client.query(`INSERT INTO company_members (company_id,user_id,role) VALUES ($1,$2,'member')`, [invitation.company_id, me]) + await client.query( + `INSERT INTO participants (id,company_id,kind,name,role,initial,avatar_bg,avatar_url,status,departed_at) + VALUES ($1,$2,'human',$3,NULL,$4,'#FF8870',$5,'avail',NULL) + ON CONFLICT (id,company_id) DO UPDATE SET name=EXCLUDED.name,avatar_url=EXCLUDED.avatar_url,status='avail',departed_at=NULL`, + [me, invitation.company_id, user.display_name, user.display_name.charAt(0).toUpperCase(), user.avatar_url ?? gravatarUrlForEmail(user.email)], + ) + } + const { rows: membership } = await client.query<{ role: 'teacher' | 'learner' }>( + `SELECT role FROM course_members WHERE course_id=$1 AND user_id=$2`, [invitation.course_id, me], + ) + const existingRole = membership[0]?.role ?? null + const isReplay = Boolean(priorAcceptance[0]) + if (isReplay && !existingRole) { + throw new HttpError(410, 'this invitation was already accepted and no longer grants course access') + } + let effectiveRole: 'teacher' | 'learner' = isReplay + ? existingRole! + : existingRole === 'teacher' || invitation.role === 'teacher' ? 'teacher' : 'learner' + const changesRole = !isReplay && (!existingRole || effectiveRole !== existingRole) + if (!priorAcceptance[0] && changesRole && invitation.use_count >= invitation.max_uses) { + throw new HttpError(410, 'invitation already used') + } + if (changesRole) { + const { rows: upsertedMembership } = await client.query<{ role: 'teacher' | 'learner' }>( + `INSERT INTO course_members (course_id,company_id,user_id,role) + VALUES ($1,$2,$3,$4) + ON CONFLICT (course_id,user_id) DO UPDATE SET + role=CASE + WHEN course_members.role='teacher' OR EXCLUDED.role='teacher' THEN 'teacher' + ELSE 'learner' + END, + updated_at=NOW() + RETURNING role`, + [invitation.course_id, invitation.company_id, me, effectiveRole], + ) + effectiveRole = upsertedMembership[0].role + } + if (!priorAcceptance[0] && changesRole) { + await client.query( + `INSERT INTO course_invitation_acceptances (token_hash,user_id,role) VALUES ($1,$2,$3)`, + [tokenHash, me, effectiveRole], + ) + await client.query( + `UPDATE course_invitations SET use_count=use_count+1,last_accepted_at=NOW(),last_accepted_by=$2 WHERE token_hash=$1`, + [tokenHash, me], + ) + } + await client.query('COMMIT') + result = { + companyId: invitation.company_id, companyName: invitation.company_name, companySlug: invitation.company_slug, + companyRole: existingCompany[0]?.role ?? 'member', + courseId: invitation.course_id, courseName: invitation.course_name, projectId: invitation.project_id, + roomId: invitation.room_id, role: effectiveRole, + alreadyMember: Boolean(existingRole) && !changesRole, joinedCompany, + } + } catch (error) { + await client.query('ROLLBACK').catch(() => undefined) + throw error + } finally { client.release() } + await syncCourseStudyRoom(result.courseId) + if (result.joinedCompany) await seedMemberDms({ companyId: result.companyId, memberId: me }).catch(() => undefined) + await audit({ kind: 'course_invitation_accept', userId: me, companyId: result.companyId, detail: { courseId: result.courseId, role: result.role } }) + res.json({ + ok: true, alreadyMember: result.alreadyMember, joinedCompany: result.joinedCompany, + company: { id: result.companyId, name: result.companyName, slug: result.companySlug, role: result.companyRole }, + course: { id: result.courseId, name: result.courseName, projectId: result.projectId, studyRoomId: result.roomId, role: result.role }, + }) +})) + /* ============== Projects ============== */ api.get('/projects', async (req, res) => { @@ -1772,11 +2610,16 @@ api.get('/projects', async (req, res) => { (SELECT COUNT(*)::int FROM boards b WHERE b.project_id = p.id) AS "boardCount", (SELECT COUNT(*)::int FROM calendar_events e WHERE e.project_id = p.id) AS "calendarEventCount", (SELECT COUNT(*)::int FROM canvases cv WHERE cv.project_id = p.id) AS "canvasCount", - (cm.role IN ('owner','admin') OR p.created_by = $2) AS "canManage" + (cm.role IN ('owner','admin') OR course_member.role='teacher') AS "canManage", + course.id AS "courseId", course_member.role AS "courseRole", + course.study_room_conversation_id AS "studyRoomId" FROM projects p JOIN company_members cm ON cm.company_id = p.company_id AND cm.user_id = $2 + LEFT JOIN courses course ON course.project_id=p.id + LEFT JOIN course_members course_member ON course_member.course_id=course.id AND course_member.user_id=$2 LEFT JOIN project_visits pv ON pv.project_id = p.id AND pv.user_id = $2 WHERE p.company_id = $1 + AND (p.is_general=TRUE OR cm.role IN ('owner','admin') OR course_member.user_id IS NOT NULL) ORDER BY p.status ASC, pv.visited_at DESC NULLS LAST, p.updated_at DESC`, [tenant, userId(req)], ) @@ -1784,7 +2627,7 @@ api.get('/projects', async (req, res) => { }) api.post('/projects', async (req, res) => { - const { companyId: tenant } = await requireCompany(req) + const { companyId: tenant } = await requireCompanyRole(req) const name = String(req.body?.name ?? '').trim().slice(0, 80) const description = String(req.body?.description ?? '').slice(0, 1000) const color = req.body?.color ? String(req.body.color).slice(0, 200) : null @@ -1808,7 +2651,8 @@ api.put('/projects/:id', async (req, res) => { // sidebar — gate to owner/admin so a single member can't unilaterally // re-brand the team's work. const workspace = await requireWorkspace(req, String(req.params.id)) - if (!PRIVILEGED_ROLES.has(workspace.role) && workspace.projectCreatedBy !== workspace.userId) throw new HttpError(403, 'only the workspace creator or a company admin can edit it') + await assertProjectWritable(workspace.projectId) + if (!PRIVILEGED_ROLES.has(workspace.role) && workspace.courseRole !== 'teacher') throw new HttpError(403, 'only a course teacher or company admin can edit it') const tenant = workspace.companyId const { id } = req.params const { rows: gate } = await pool.query( @@ -1837,7 +2681,7 @@ api.post('/projects/:id/archive', async (req, res) => { // the active list); owner/admin only. const workspace = await requireWorkspace(req, String(req.params.id)) if (workspace.isGeneral) throw new HttpError(400, 'the General workspace cannot be archived') - if (!PRIVILEGED_ROLES.has(workspace.role) && workspace.projectCreatedBy !== workspace.userId) throw new HttpError(403, 'only the workspace creator or a company admin can archive it') + if (!PRIVILEGED_ROLES.has(workspace.role) && workspace.courseRole !== 'teacher') throw new HttpError(403, 'only a course teacher or company admin can archive it') const tenant = workspace.companyId const { id } = req.params const archive = req.body?.archive !== false @@ -1915,6 +2759,7 @@ api.get('/projects/:id/sources/:sourceId', safe(async (req, res) => { api.post('/projects/:id/sources', safe(async (req, res) => { requireOpenNotebook() const workspace = await requireWorkspace(req, String(req.params.id)) + await assertProjectWritable(workspace.projectId) const kind = String(req.body?.kind ?? '').trim() if (kind !== 'text' && kind !== 'url') throw new HttpError(400, 'kind must be text or url') const rawText = kind === 'text' ? String(req.body?.text ?? '').trim() : null @@ -1938,6 +2783,7 @@ api.post('/projects/:id/sources', safe(async (req, res) => { api.post('/projects/:id/sources/upload', safe(async (req, res) => { requireOpenNotebook() const workspace = await requireWorkspace(req, String(req.params.id)) + await assertProjectWritable(workspace.projectId) const name = String(req.body?.name ?? '').trim().slice(0, 200) const mime = String(req.body?.mime ?? '').trim().toLowerCase() const size = Number(req.body?.size ?? 0) @@ -1963,6 +2809,7 @@ api.post('/projects/:id/sources/upload', safe(async (req, res) => { api.post('/projects/:id/sources/upload/presign', safe(async (req, res) => { requireOpenNotebook() const workspace = await requireWorkspace(req, String(req.params.id)) + await assertProjectWritable(workspace.projectId) if (storage.mode !== 'r2') throw new HttpError(501, 'presigned source upload is not available in local storage mode') const name = String(req.body?.name ?? '').trim().slice(0, 200) const mime = String(req.body?.mime ?? '').trim().toLowerCase() @@ -1984,6 +2831,7 @@ api.post('/projects/:id/sources/upload/presign', safe(async (req, res) => { api.post('/projects/:id/sources/:sourceId/complete-upload', safe(async (req, res) => { requireOpenNotebook() const workspace = await requireWorkspace(req, String(req.params.id)) + await assertProjectWritable(workspace.projectId) const { rows } = await pool.query<{ storage_key: string; size_bytes: number; created_by: string }>( `SELECT storage_key, size_bytes, created_by FROM knowledge_sources WHERE id=$1 AND company_id=$2 AND project_id=$3 AND status='upload_pending' AND deleted_at IS NULL`, @@ -2000,6 +2848,7 @@ api.post('/projects/:id/sources/:sourceId/complete-upload', safe(async (req, res api.post('/projects/:id/sources/:sourceId/retry', safe(async (req, res) => { requireOpenNotebook() const workspace = await requireWorkspace(req, String(req.params.id)) + await assertProjectWritable(workspace.projectId) const { rows } = await pool.query<{ created_by: string }>( `SELECT created_by FROM knowledge_sources WHERE id = $1 AND project_id = $2 AND deleted_at IS NULL`, [req.params.sourceId, workspace.projectId], @@ -2013,6 +2862,7 @@ api.post('/projects/:id/sources/:sourceId/retry', safe(async (req, res) => { api.delete('/projects/:id/sources/:sourceId', safe(async (req, res) => { requireOpenNotebook() const workspace = await requireWorkspace(req, String(req.params.id)) + await assertProjectWritable(workspace.projectId) const { rows } = await pool.query<{ created_by: string; storage_key: string | null }>( `SELECT created_by, storage_key FROM knowledge_sources WHERE id = $1 AND project_id = $2 AND company_id = $3 AND deleted_at IS NULL`, @@ -2070,6 +2920,7 @@ api.get('/conversations/:id/sources/:sourceId', safe(async (req, res) => { api.post('/conversations/:id/sources', safe(async (req, res) => { requireOpenNotebook() const membership = await requireGroupConversation(req, String(req.params.id)) + await assertProjectWritable(membership.projectId) if (!membership.projectId) throw new HttpError(409, 'conversation has no workspace') const kind = String(req.body?.kind ?? '').trim() if (kind !== 'text' && kind !== 'url') throw new HttpError(400, 'kind must be text or url') @@ -2094,6 +2945,7 @@ api.post('/conversations/:id/sources', safe(async (req, res) => { api.post('/conversations/:id/sources/upload', safe(async (req, res) => { requireOpenNotebook() const membership = await requireGroupConversation(req, String(req.params.id)) + await assertProjectWritable(membership.projectId) if (!membership.projectId) throw new HttpError(409, 'conversation has no workspace') const name = String(req.body?.name ?? '').trim().slice(0, 200) const mime = String(req.body?.mime ?? '').trim().toLowerCase() @@ -2119,6 +2971,7 @@ api.post('/conversations/:id/sources/upload', safe(async (req, res) => { api.post('/conversations/:id/sources/upload/presign', safe(async (req, res) => { requireOpenNotebook() const membership = await requireGroupConversation(req, String(req.params.id)) + await assertProjectWritable(membership.projectId) if (!membership.projectId) throw new HttpError(409, 'conversation has no workspace') if (storage.mode !== 'r2') throw new HttpError(501, 'presigned source upload is not available in local storage mode') const name = String(req.body?.name ?? '').trim().slice(0, 200) @@ -2137,6 +2990,7 @@ api.post('/conversations/:id/sources/upload/presign', safe(async (req, res) => { api.post('/conversations/:id/sources/:sourceId/complete-upload', safe(async (req, res) => { requireOpenNotebook() const membership = await requireGroupConversation(req, String(req.params.id)) + await assertProjectWritable(membership.projectId) if (!membership.projectId) throw new HttpError(409, 'conversation has no workspace') const { rows } = await pool.query<{ storage_key: string; size_bytes: number; created_by: string }>(`SELECT storage_key, size_bytes, created_by FROM knowledge_sources WHERE id=$1 AND project_id=$2 AND company_id=$3 AND status='upload_pending' AND deleted_at IS NULL`, [req.params.sourceId, membership.projectId, membership.companyId]) if (!rows[0]) throw new HttpError(404, 'pending source upload not found') @@ -2149,6 +3003,7 @@ api.post('/conversations/:id/sources/:sourceId/complete-upload', safe(async (req api.post('/conversations/:id/sources/:sourceId/retry', safe(async (req, res) => { requireOpenNotebook() const membership = await requireGroupConversation(req, String(req.params.id)) + await assertProjectWritable(membership.projectId) if (!membership.projectId) throw new HttpError(409, 'conversation has no workspace') const { rows } = await pool.query<{ created_by: string }>(`SELECT created_by FROM knowledge_sources WHERE id=$1 AND project_id=$2 AND company_id=$3 AND deleted_at IS NULL`, [req.params.sourceId, membership.projectId, membership.companyId]) if (!rows[0]) throw new HttpError(404, 'source not found') @@ -2159,6 +3014,7 @@ api.post('/conversations/:id/sources/:sourceId/retry', safe(async (req, res) => api.delete('/conversations/:id/sources/:sourceId', safe(async (req, res) => { requireOpenNotebook() const membership = await requireGroupConversation(req, String(req.params.id)) + await assertProjectWritable(membership.projectId) if (!membership.projectId) throw new HttpError(409, 'conversation has no workspace') const { rows } = await pool.query<{ created_by: string; storage_key: string | null }>(`SELECT created_by, storage_key FROM knowledge_sources WHERE id=$1 AND project_id=$2 AND company_id=$3 AND deleted_at IS NULL`, [req.params.sourceId, membership.projectId, membership.companyId]) if (!rows[0]) throw new HttpError(404, 'source not found') @@ -2170,6 +3026,7 @@ api.delete('/conversations/:id/sources/:sourceId', safe(async (req, res) => { api.put('/conversations/:id/sources', safe(async (req, res) => { requireOpenNotebook() const membership = await requireGroupConversation(req, String(req.params.id)) + await assertProjectWritable(membership.projectId) if (!membership.projectId) throw new HttpError(409, 'conversation has no workspace') const excluded = Array.isArray(req.body?.excludedSourceIds) ? [...new Set(req.body.excludedSourceIds.map(String))].slice(0, 500) : [] const client = await pool.connect() @@ -2201,11 +3058,8 @@ api.post('/conversations/:id/project', async (req, res) => { if (!rows[0].members.includes(me)) { res.status(403).json({ error: 'only members can change the project' }); return } - const { rows: pj } = await pool.query( - `SELECT 1 FROM projects WHERE id = $1 AND company_id = $2 AND archived_at IS NULL LIMIT 1`, - [projectId, tenant], - ) - if (!pj[0]) { res.status(400).json({ error: 'unknown project' }); return } + const target = await requireWorkspace(req, projectId) + if (target.projectStatus !== 'active') { res.status(409).json({ error: 'archived courses are read-only' }); return } await pool.query( `UPDATE conversations SET project_id = $2, updated_at = NOW() WHERE id = $1 AND company_id = $3`, [id, projectId, tenant], @@ -2214,7 +3068,7 @@ api.post('/conversations/:id/project', async (req, res) => { }) api.get('/participants', async (req, res) => { - const { companyId: tenant } = await requireCompany(req) + const { companyId: tenant, projectId } = await requireCompanyArtifactContext(req) await pool.query( `UPDATE participants SET status = 'avail', @@ -2261,8 +3115,21 @@ api.get('/participants', async (req, res) => { ON cm.user_id = p.id AND cm.company_id = p.company_id LEFT JOIN users u ON u.id = cm.user_id WHERE p.company_id = $1 + AND ( + p.kind = 'agent' + OR EXISTS ( + SELECT 1 + FROM projects selected_project + LEFT JOIN courses selected_course ON selected_course.project_id = selected_project.id + LEFT JOIN course_members selected_member + ON selected_member.course_id = selected_course.id AND selected_member.user_id = p.id + WHERE selected_project.id = $2 + AND selected_project.company_id = p.company_id + AND (selected_project.is_general = TRUE OR selected_member.user_id IS NOT NULL) + ) + ) ORDER BY p.kind DESC, p.name ASC`, - [tenant], + [tenant, projectId], ) // Compute deterministic addresses for agents who haven't been // lazy-minted yet — without this, the renderer's recipient picker hides @@ -3121,7 +3988,7 @@ api.post('/agents/:id/rehire', async (req, res) => { }) api.get('/conversations', async (req, res) => { - const { userId: me, companyId: tenant } = await requireCompany(req) + const { userId: me, companyId: tenant, projectId } = await requireCompanyArtifactContext(req) const { rows } = await pool.query( `SELECT c.id, c.kind, @@ -3186,6 +4053,7 @@ api.get('/conversations', async (req, res) => { LIMIT 1 ) other_participant ON c.kind = 'direct' WHERE c.company_id = $2 + AND c.project_id = $3 -- Only conversations the caller is actually in. Without this, -- agent-to-agent direct chats (members=[agentA, agentB]) leak -- into the user's list even though they're not a participant @@ -3193,7 +4061,7 @@ api.get('/conversations', async (req, res) => { -- "Whispers" peek tab. AND c.members @> to_jsonb(ARRAY[$1::text]) ORDER BY c.pinned DESC, c.updated_at DESC`, - [me, tenant], + [me, tenant, projectId], ) res.json(rows) }) @@ -3217,17 +4085,9 @@ api.post('/conversations', async (req, res) => { if (members.length < 2) { res.status(400).json({ error: 'pick at least one teammate' }); return } const requestedProjectId = typeof req.body?.workspaceId === 'string' ? req.body.workspaceId.trim() : '' - const { rows: workspaces } = await pool.query<{ id: string }>( - requestedProjectId - ? `SELECT id FROM projects WHERE id = $1 AND company_id = $2 AND status = 'active' LIMIT 1` - : `SELECT id FROM projects WHERE company_id = $1 AND is_general = TRUE AND status = 'active' LIMIT 1`, - requestedProjectId ? [requestedProjectId, tenant] : [tenant], - ) - const projectId = workspaces[0]?.id - if (!projectId) { - res.status(requestedProjectId ? 400 : 409).json({ error: requestedProjectId ? 'workspace not found' : 'General workspace unavailable' }) - return - } + const projectId = requestedProjectId || await companyArtifactBucket(tenant) + const workspace = await requireWorkspace(req, projectId) + if (workspace.projectStatus !== 'active') { res.status(409).json({ error: 'archived courses are read-only' }); return } // Validate every member exists in this tenant. const { rows: existing } = await pool.query<{ id: string; kind: string; departed_at: string | null }>( @@ -3243,6 +4103,16 @@ api.post('/conversations', async (req, res) => { if (!leader || leader.kind !== 'agent' || leader.departed_at) { res.status(400).json({ error: 'leaderId must be an active agent member' }); return } + if (workspace.courseId) { + const humanIds = existing.filter((member) => member.kind === 'human').map((member) => member.id) + const { rows: enrolled } = await pool.query<{ user_id: string }>( + `SELECT user_id FROM course_members WHERE course_id = $1 AND user_id = ANY($2::text[])`, + [workspace.courseId, humanIds], + ) + const enrolledIds = new Set(enrolled.map((member) => member.user_id)) + const outsiders = humanIds.filter((memberId) => !enrolledIds.has(memberId)) + if (outsiders.length > 0) { res.status(400).json({ error: 'all human members must belong to the course' }); return } + } const id = `g-${randomUUID().slice(0, 8)}` await pool.query( @@ -3256,8 +4126,9 @@ api.post('/conversations', async (req, res) => { /** Change a group's leader. Any human member may choose an active agent member. */ api.post('/conversations/:id/leader', async (req, res) => { - const { userId: me, companyId: tenant } = await requireCompany(req) const { id } = req.params + const { userId: me, companyId: tenant, projectId } = await requireConversationMember(req, id) + await assertProjectWritable(projectId) const leaderId = typeof req.body?.leaderId === 'string' ? req.body.leaderId.trim() : '' if (!leaderId) { res.status(400).json({ error: 'leaderId required' }); return } const { rows } = await pool.query<{ members: string[]; kind: string }>( @@ -3279,15 +4150,16 @@ api.post('/conversations/:id/leader', async (req, res) => { [id, leaderId, tenant], ) await publish(CH_CONVO_UPDATED, { - type: 'conversation.updated', conversationId: id, companyId: tenant, patch: { leaderId }, + type: 'conversation.updated', conversationId: id, companyId: tenant, workspaceId: projectId ?? undefined, patch: { leaderId }, }) res.json({ ok: true, leaderId }) }) /** Set or clear a conversation's topic. Any member can change it. */ api.post('/conversations/:id/topic', async (req, res) => { - const { userId: me, companyId: tenant } = await requireCompany(req) const { id } = req.params + const { userId: me, companyId: tenant, projectId } = await requireConversationMember(req, id) + await assertProjectWritable(projectId) const raw = req.body?.topic const topic = raw === null || raw === '' ? null : (typeof raw === 'string' ? raw.trim().slice(0, 200) : null) const { rows } = await pool.query<{ members: string[] }>( @@ -3305,6 +4177,7 @@ api.post('/conversations/:id/topic', async (req, res) => { type: 'conversation.updated', conversationId: id, companyId: tenant, + workspaceId: projectId ?? undefined, patch: { topic }, }) res.json({ ok: true, topic }) @@ -3313,8 +4186,9 @@ api.post('/conversations/:id/topic', async (req, res) => { /** Rename a group conversation. Members only; groups only — a DM's title is the * other person's name (derived), so renaming it doesn't apply. */ api.post('/conversations/:id/title', async (req, res) => { - const { userId: me, companyId: tenant } = await requireCompany(req) const { id } = req.params + const { userId: me, companyId: tenant, projectId } = await requireConversationMember(req, id) + await assertProjectWritable(projectId) const title = String(req.body?.title ?? '').trim().slice(0, 80) if (!title) { res.status(400).json({ error: 'title required' }); return } const { rows } = await pool.query<{ members: string[]; kind: string }>( @@ -3333,6 +4207,7 @@ api.post('/conversations/:id/title', async (req, res) => { type: 'conversation.updated', conversationId: id, companyId: tenant, + workspaceId: projectId ?? undefined, patch: { title }, }) res.json({ ok: true, title }) @@ -3342,7 +4217,8 @@ api.post('/conversations/:id/title', async (req, res) => { * given participant. Idempotent — clicking the DM button repeatedly always * resolves to the same conversation. */ api.post('/conversations/direct', async (req, res) => { - const { userId: me, companyId: tenant } = await requireCompany(req) + const { userId: me, companyId: tenant, projectId } = await requireCompanyArtifactContext(req, true) + const workspace = await requireWorkspace(req, projectId) const otherId = String(req.body?.otherId ?? '').trim() if (!otherId) { res.status(400).json({ error: 'otherId required' }); return } if (otherId === me) { res.status(400).json({ error: 'cannot DM yourself' }); return } @@ -3350,15 +4226,21 @@ api.post('/conversations/direct', async (req, res) => { `SELECT id, kind FROM participants WHERE id = $1 AND company_id = $2`, [otherId, tenant], ) if (!pp[0]) { res.status(404).json({ error: 'unknown participant' }); return } + if (workspace.courseId && pp[0].kind === 'human') { + const { rows: enrollment } = await pool.query( + `SELECT 1 FROM course_members WHERE course_id=$1 AND user_id=$2`, [workspace.courseId, otherId], + ) + if (!enrollment[0]) { res.status(404).json({ error: 'unknown participant' }); return } + } // Look for an existing direct chat with exactly these two members. const { rows: existing } = await pool.query<{ id: string }>( `SELECT id FROM conversations - WHERE kind = 'direct' AND company_id = $3 + WHERE kind = 'direct' AND company_id = $3 AND project_id = $4 AND members @> to_jsonb(ARRAY[$1::text]) AND members @> to_jsonb(ARRAY[$2::text]) AND jsonb_array_length(members) = 2 ORDER BY updated_at DESC LIMIT 1`, - [me, otherId, tenant], + [me, otherId, tenant, projectId], ) if (existing[0]) { res.json({ id: existing[0].id, created: false }); return } @@ -3367,9 +4249,9 @@ api.post('/conversations/direct', async (req, res) => { `SELECT name FROM participants WHERE id = $1 AND company_id = $2`, [otherId, tenant], ) await pool.query( - `INSERT INTO conversations (id, kind, title, subtitle, members, pinned, tag, company_id) - VALUES ($1, 'direct', $2, NULL, $3::jsonb, FALSE, $4, $5)`, - [id, title[0]?.name ?? otherId, JSON.stringify([me, otherId]), pp[0].kind === 'human' ? 'human' : null, tenant], + `INSERT INTO conversations (id, kind, title, subtitle, members, pinned, tag, company_id, project_id) + VALUES ($1, 'direct', $2, NULL, $3::jsonb, FALSE, $4, $5, $6)`, + [id, title[0]?.name ?? otherId, JSON.stringify([me, otherId]), pp[0].kind === 'human' ? 'human' : null, tenant, projectId], ) await pool.query(`INSERT INTO conversation_counters (conversation_id, next_sequence) VALUES ($1, 1)`, [id]) res.status(201).json({ id, created: true }) @@ -3382,6 +4264,7 @@ api.post('/conversations/:id/pin', async (req, res) => { // Without a membership gate, any tenant member could pin/unpin a private // DM they're not part of, mutating UI state for the real members. const { companyId: tenant } = await requireConversationMember(req, id) + await assertConversationWritable(tenant, id) const { rows } = await pool.query<{ pinned: boolean }>( `SELECT pinned FROM conversations WHERE id = $1 AND company_id = $2`, [id, tenant], ) @@ -3456,8 +4339,9 @@ api.post('/conversations/:id/mute', async (req, res) => { * ensures the new member is in `members` when CH_MESSAGE_NEW fires, * so the mailbox scheduler wakes them and they perceive the join. */ api.post('/conversations/:id/members', async (req, res) => { - const { userId: me, companyId: tenant } = await requireCompany(req) const { id } = req.params + const { userId: me, companyId: tenant, projectId } = await requireConversationMember(req, id) + await assertProjectWritable(projectId) const newMember = String(req.body?.id ?? '').trim() if (!newMember) { res.status(400).json({ error: 'id required' }); return } const { rows } = await pool.query<{ kind: string; members: string[] }>( @@ -3470,7 +4354,16 @@ api.post('/conversations/:id/members', async (req, res) => { if (c.members.includes(newMember)) { res.json({ ok: true, members: c.members, alreadyIn: true }); return } // Validate participant exists in this tenant. const { rows: existing } = await pool.query<{ id: string }>( - `SELECT id FROM participants WHERE id = $1 AND company_id = $2`, [newMember, tenant], + `SELECT participant.id FROM participants participant + WHERE participant.id=$1 AND participant.company_id=$2 + AND ( + participant.kind='agent' + OR NOT EXISTS (SELECT 1 FROM courses WHERE project_id=$3) + OR EXISTS ( + SELECT 1 FROM courses course JOIN course_members member ON member.course_id=course.id + WHERE course.project_id=$3 AND member.user_id=participant.id + ) + )`, [newMember, tenant, projectId], ) if (!existing[0]) { res.status(400).json({ error: `unknown participant: ${newMember}` }); return } const next = [...c.members, newMember] @@ -3491,8 +4384,9 @@ api.post('/conversations/:id/members', async (req, res) => { * caller's mailbox surfaces this final row in their next wake (the * inbox query filters by current `c.members @> [me]`). */ api.post('/conversations/:id/leave', async (req, res) => { - const { userId: me, companyId: tenant } = await requireCompany(req) const { id } = req.params + const { userId: me, companyId: tenant, projectId } = await requireConversationMember(req, id) + await assertProjectWritable(projectId) const { rows } = await pool.query<{ kind: string; members: string[] }>( `SELECT kind, members FROM conversations WHERE id = $1 AND company_id = $2`, [id, tenant], ) @@ -3771,6 +4665,7 @@ api.get('/conversations/:id/messages', async (req, res) => { api.post('/conversations/:id/messages', async (req, res) => { const { id } = req.params const { userId: me, companyId: tenant, projectId } = await requireConversationMember(req, String(id)) + await assertProjectWritable(projectId) const body = String(req.body?.body ?? '').trim() const rawAttachment = req.body?.attachment let attachment: AttachmentPayload | null = null @@ -4005,6 +4900,7 @@ api.post('/polls', async (req, res) => { const body = req.body ?? {} const conversationId = String(body.conversationId ?? '') if (!conversationId) { res.status(400).json({ error: 'conversationId required' }); return } + await assertConversationWritable(tenant, conversationId) const optionsRaw = Array.isArray(body.options) ? body.options : [] const created = await createPoll({ conversationId, @@ -4025,6 +4921,7 @@ api.post('/polls/:messageId/vote', async (req, res) => { try { const { userId: me, companyId: tenant } = await requireCompany(req) const messageId = req.params.messageId + await assertPollConversationWritable(tenant, messageId) const rawOptionIds = Array.isArray(req.body?.optionIds) ? req.body.optionIds : [] const optionIds = rawOptionIds.map((x: unknown) => String(x ?? '')).filter(Boolean) const event = await castVote({ @@ -4042,6 +4939,7 @@ api.post('/polls/:messageId/close', async (req, res) => { try { const { userId: me, companyId: tenant } = await requireCompany(req) const messageId = req.params.messageId + await assertPollConversationWritable(tenant, messageId) const event = await closePoll({ messageId, companyId: tenant, @@ -4658,7 +5556,7 @@ api.post('/messages/:id/reactions', async (req, res) => { * rooms the caller isn't actually in (same guard `/conversations` uses). */ api.get('/search', async (req, res) => { - const { userId: me, companyId: tenant } = await requireCompany(req) + const { userId: me, companyId: tenant, projectId } = await requireCompanyArtifactContext(req) const raw = typeof req.query.q === 'string' ? req.query.q.trim() : '' if (!raw) { res.json({ participants: [], rooms: [], groups: [], messages: [] }) @@ -4687,6 +5585,17 @@ api.get('/search', async (req, res) => { FROM participants WHERE company_id = $1 AND departed_at IS NULL + AND ( + kind = 'agent' + OR EXISTS ( + SELECT 1 FROM projects selected_project + LEFT JOIN courses selected_course ON selected_course.project_id=selected_project.id + LEFT JOIN course_members selected_member + ON selected_member.course_id=selected_course.id AND selected_member.user_id=participants.id + WHERE selected_project.id=$5 + AND (selected_project.is_general=TRUE OR selected_member.user_id IS NOT NULL) + ) + ) AND (name ILIKE $2 ESCAPE '\\' OR role ILIKE $2 ESCAPE '\\' OR id ILIKE $2 ESCAPE '\\') ORDER BY CASE WHEN lower(name) = lower($3) THEN 0 @@ -4697,7 +5606,7 @@ api.get('/search', async (req, res) => { CASE kind WHEN 'agent' THEN 0 ELSE 1 END, name LIMIT ${P_LIMIT}`, - [tenant, contains, exact, prefix], + [tenant, contains, exact, prefix, projectId], ) // 1-on-1 rooms (direct + whisper): direct titles are perspective-specific, @@ -4725,6 +5634,7 @@ api.get('/search', async (req, res) => { LIMIT 1 ) other_participant ON c.kind = 'direct' WHERE c.company_id = $1 + AND c.project_id = $6 AND c.kind IN ('direct', 'whisper') AND c.members @> to_jsonb(ARRAY[$2::text]) ) @@ -4744,7 +5654,7 @@ api.get('/search', async (req, res) => { ELSE 2 END, r.updated_at DESC LIMIT ${R_LIMIT}`, - [tenant, me, contains, exact, prefix], + [tenant, me, contains, exact, prefix, projectId], ) const groupsP = pool.query( @@ -4752,6 +5662,7 @@ api.get('/search', async (req, res) => { FROM conversations c LEFT JOIN projects p ON p.id = c.project_id WHERE c.company_id = $1 + AND c.project_id = $6 AND c.kind = 'group' AND c.members @> to_jsonb(ARRAY[$2::text]) AND (c.title ILIKE $3 ESCAPE '\\' OR (c.topic IS NOT NULL AND c.topic ILIKE $3 ESCAPE '\\')) @@ -4761,7 +5672,7 @@ api.get('/search', async (req, res) => { ELSE 2 END, c.updated_at DESC LIMIT ${G_LIMIT}`, - [tenant, me, contains, exact, prefix], + [tenant, me, contains, exact, prefix, projectId], ) // Skip `tool` / `system` rows — those bodies are machine output, not @@ -4793,12 +5704,13 @@ api.get('/search', async (req, res) => { LIMIT 1 ) other_participant ON c.kind = 'direct' WHERE c.company_id = $1 + AND c.project_id = $4 AND c.members @> to_jsonb(ARRAY[$2::text]) AND m.kind = 'text' AND m.body ILIKE $3 ESCAPE '\\' ORDER BY m.created_at DESC LIMIT ${M_LIMIT}`, - [tenant, me, contains], + [tenant, me, contains, projectId], ) const [participants, rooms, groups, messages] = await Promise.all([ @@ -5336,11 +6248,11 @@ async function parseMentions(companyId: string, text: string): Promise * company. Throws 404 if the board doesn't exist OR lives in another * tenant — kept opaque so a probing client can't enumerate cross-tenant * board ids. */ -async function requireBoardAccess(req: Request & AuthedRequest, boardId: string): Promise<{ userId: string; companyId: string; projectId: string }> { - const { userId, companyId, projectId } = await requireCompanyArtifactContext(req) +async function requireBoardAccess(req: Request & AuthedRequest, boardId: string, writable = false): Promise<{ userId: string; companyId: string; projectId: string }> { + const { userId, companyId, projectId } = await requireCompanyArtifactContext(req, writable) const { rows } = await pool.query<{ company_id: string }>( - `SELECT company_id FROM boards WHERE id = $1 AND company_id = $2 LIMIT 1`, - [boardId, companyId], + `SELECT company_id FROM boards WHERE id = $1 AND company_id = $2 AND project_id = $3 LIMIT 1`, + [boardId, companyId, projectId], ) if (!rows[0] || rows[0].company_id !== companyId) throw new HttpError(404, 'not found') return { userId, companyId, projectId } @@ -5407,14 +6319,14 @@ async function wakeMentionedAgents(args: { /** GET /boards — list every board in the active workspace. */ api.get('/boards', async (req, res) => { - const { companyId } = await requireCompany(req) + const { companyId, projectId } = await requireCompanyArtifactContext(req) const { rows } = await pool.query<{ id: string; title: string; description: string | null created_by: string; created_at: string; updated_at: string }>( `SELECT id, title, description, created_by, created_at, updated_at - FROM boards WHERE company_id = $1 ORDER BY updated_at DESC`, - [companyId], + FROM boards WHERE company_id = $1 AND project_id = $2 ORDER BY updated_at DESC`, + [companyId, projectId], ) res.json(rows.map((r) => ({ id: r.id, @@ -5431,7 +6343,7 @@ api.get('/boards', async (req, res) => { * this lookup to open the right board peek without forcing agents to also * spell out the board id in prose. */ api.get('/cards/:id', async (req, res) => { - const { companyId } = await requireCompany(req) + const { companyId, projectId } = await requireCompanyArtifactContext(req) const cardId = req.params.id const { rows } = await pool.query<{ id: string @@ -5466,9 +6378,9 @@ api.get('/cards/:id', async (req, res) => { FROM board_cards c JOIN boards b ON b.id = c.board_id JOIN board_columns col ON col.id = c.column_id - WHERE c.id = $1 AND b.company_id = $2 + WHERE c.id = $1 AND b.company_id = $2 AND b.project_id = $3 LIMIT 1`, - [cardId, companyId], + [cardId, companyId, projectId], ) const r = rows[0] if (!r) throw new HttpError(404, 'not found') @@ -5507,7 +6419,7 @@ api.get('/cards/:id', async (req, res) => { /** POST /boards — create a board. Auto-seeds the conventional "Todo / * Doing / Done" columns so the new board is immediately usable. */ api.post('/boards', async (req, res) => { - const { userId: me, companyId, projectId } = await requireCompanyArtifactContext(req) + const { userId: me, companyId, projectId } = await requireCompanyArtifactContext(req, true) const title = String(req.body?.title ?? '').trim().slice(0, 200) const description = String(req.body?.description ?? '').trim().slice(0, 4000) || null if (!title) throw new HttpError(400, 'title required') @@ -5607,7 +6519,7 @@ api.get('/boards/:id', async (req, res) => { /** PATCH /boards/:id — rename / re-describe. */ api.patch('/boards/:id', async (req, res) => { const boardId = req.params.id - const { userId: me, companyId, projectId } = await requireBoardAccess(req, boardId) + const { userId: me, companyId } = await requireBoardAccess(req, boardId, true) const patch: Record = {} if (typeof req.body?.title === 'string') patch.title = req.body.title.trim().slice(0, 200) if (typeof req.body?.description === 'string') patch.description = req.body.description.trim().slice(0, 4000) || null @@ -5629,7 +6541,7 @@ api.patch('/boards/:id', async (req, res) => { /** DELETE /boards/:id — full drop, columns + cards + comments cascade. */ api.delete('/boards/:id', async (req, res) => { const boardId = req.params.id - const { userId: me, companyId, projectId } = await requireBoardAccess(req, boardId) + const { userId: me, companyId, projectId } = await requireBoardAccess(req, boardId, true) await pool.query(`DELETE FROM boards WHERE id = $1`, [boardId]) await publishBoardEvent({ companyId, workspaceId: projectId, kind: 'board.deleted', boardId, actorId: me }) res.json({ ok: true }) @@ -5638,7 +6550,7 @@ api.delete('/boards/:id', async (req, res) => { /** POST /boards/:id/columns — add a new column at the end. */ api.post('/boards/:id/columns', async (req, res) => { const boardId = req.params.id - const { userId: me, companyId } = await requireBoardAccess(req, boardId) + const { userId: me, companyId } = await requireBoardAccess(req, boardId, true) const title = String(req.body?.title ?? '').trim().slice(0, 100) if (!title) throw new HttpError(400, 'title required') const { rows: posRows } = await pool.query<{ max: number | null }>( @@ -5658,7 +6570,7 @@ api.post('/boards/:id/columns', async (req, res) => { api.patch('/boards/:bid/columns/:cid', async (req, res) => { const boardId = req.params.bid const columnId = req.params.cid - const { userId: me, companyId } = await requireBoardAccess(req, boardId) + const { userId: me, companyId } = await requireBoardAccess(req, boardId, true) const sets: string[] = [] const params: unknown[] = [] if (typeof req.body?.title === 'string') { @@ -5682,7 +6594,7 @@ api.patch('/boards/:bid/columns/:cid', async (req, res) => { api.delete('/boards/:bid/columns/:cid', async (req, res) => { const boardId = req.params.bid const columnId = req.params.cid - const { userId: me, companyId } = await requireBoardAccess(req, boardId) + const { userId: me, companyId } = await requireBoardAccess(req, boardId, true) const r = await pool.query( `DELETE FROM board_columns WHERE id = $1 AND board_id = $2`, [columnId, boardId], @@ -5696,7 +6608,7 @@ api.delete('/boards/:bid/columns/:cid', async (req, res) => { * the destination column. Title/description parsed for @-mentions. */ api.post('/boards/:id/cards', async (req, res) => { const boardId = req.params.id - const { userId: me, companyId } = await requireBoardAccess(req, boardId) + const { userId: me, companyId } = await requireBoardAccess(req, boardId, true) const title = String(req.body?.title ?? '').trim().slice(0, 200) const description = String(req.body?.description ?? '').trim().slice(0, 8000) || null const columnId = String(req.body?.columnId ?? '').trim() @@ -5740,7 +6652,7 @@ api.post('/boards/:id/cards', async (req, res) => { api.patch('/boards/:bid/cards/:cid', async (req, res) => { const boardId = req.params.bid const cardId = req.params.cid - const { userId: me, companyId } = await requireBoardAccess(req, boardId) + const { userId: me, companyId } = await requireBoardAccess(req, boardId, true) // Load current row so we can parse mentions off (possibly partial) input // against the existing title/description and decide which broadcast kind // to publish (card.moved vs card.updated). @@ -5819,7 +6731,7 @@ api.patch('/boards/:bid/cards/:cid', async (req, res) => { api.delete('/boards/:bid/cards/:cid', async (req, res) => { const boardId = req.params.bid const cardId = req.params.cid - const { userId: me, companyId } = await requireBoardAccess(req, boardId) + const { userId: me, companyId } = await requireBoardAccess(req, boardId, true) const r = await pool.query( `DELETE FROM board_cards WHERE id = $1 AND board_id = $2`, [cardId, boardId], @@ -5860,7 +6772,7 @@ api.get('/boards/:bid/cards/:cid/comments', async (req, res) => { api.post('/boards/:bid/cards/:cid/comments', async (req, res) => { const boardId = req.params.bid const cardId = req.params.cid - const { userId: me, companyId } = await requireBoardAccess(req, boardId) + const { userId: me, companyId } = await requireBoardAccess(req, boardId, true) const body = String(req.body?.body ?? '').trim().slice(0, 8000) if (!body) throw new HttpError(400, 'body required') const card = await pool.query( @@ -5890,7 +6802,7 @@ api.delete('/boards/:bid/cards/:cid/comments/:mid', async (req, res) => { const boardId = req.params.bid const cardId = req.params.cid const mid = req.params.mid - const { userId: me, companyId } = await requireBoardAccess(req, boardId) + const { userId: me, companyId } = await requireBoardAccess(req, boardId, true) const r = await pool.query( `DELETE FROM board_card_comments WHERE id = $1 AND card_id = $2 AND author_id = $3`, @@ -6031,7 +6943,7 @@ function rowToCalendarEvent(row: Record): CalendarEventPayload } } -const CALENDAR_SELECT = `id, company_id, created_by, kind, title, description, +const CALENDAR_SELECT = `id,company_id,project_id,created_by,kind,title,description, assignee_id, target_conversation_id, agent_prompt, start_at, end_at, all_day, recurrence, status, last_fired_at, reminder_minutes_before, reminder_channel, @@ -6119,7 +7031,7 @@ api.get('/calendar/events', async (req, res) => { }) api.post('/calendar/events', async (req, res) => { - const { userId: me, companyId, projectId } = await requireCompanyArtifactContext(req) + const { userId: me, companyId, projectId } = await requireCompanyArtifactContext(req, true) const body = req.body as Record | undefined if (!body || typeof body !== 'object') throw new HttpError(400, 'body required') @@ -6226,7 +7138,7 @@ api.get('/calendar/events/:id', async (req, res) => { }) api.patch('/calendar/events/:id', async (req, res) => { - const { userId: me, companyId, projectId } = await requireCompanyArtifactContext(req) + const { userId: me, companyId, projectId } = await requireCompanyArtifactContext(req, true) const id = String(req.params.id) const body = req.body as Record | undefined if (!body || typeof body !== 'object') throw new HttpError(400, 'body required') @@ -6332,7 +7244,7 @@ api.patch('/calendar/events/:id', async (req, res) => { }) api.delete('/calendar/events/:id', async (req, res) => { - const { userId: me, companyId, projectId } = await requireCompanyArtifactContext(req) + const { userId: me, companyId, projectId } = await requireCompanyArtifactContext(req, true) const id = String(req.params.id) // The visibility clause is folded into the DELETE so the same caller // who can't read the row can't delete it either. rowCount === 0 covers @@ -6348,7 +7260,7 @@ api.delete('/calendar/events/:id', async (req, res) => { }) api.post('/calendar/events/:id/run-now', async (req, res) => { - const { userId: me, companyId, projectId } = await requireCompanyArtifactContext(req) + const { userId: me, companyId, projectId } = await requireCompanyArtifactContext(req, true) const id = String(req.params.id) const { rows } = await pool.query( `SELECT ${CALENDAR_SELECT} FROM calendar_events @@ -6443,32 +7355,33 @@ async function publishDocumentChanged( documentId: string, kind: 'document.created' | 'document.updated' | 'document.deleted', actorId: string, + workspaceId: string, ): Promise { await publish(CH_DOCS, { type: 'doc.changed', kind, companyId, + workspaceId, documentId, actorId, }) } api.get('/documents', safe(async (req, res) => { - const { companyId } = await requireCompany(req) + const { companyId, projectId } = await requireCompanyArtifactContext(req) const { rows } = await pool.query( `SELECT id, company_id, title, created_by, conversation_id, created_at, updated_at FROM documents - WHERE company_id = $1 + WHERE company_id = $1 AND project_id = $2 ORDER BY updated_at DESC LIMIT 200`, - [companyId], + [companyId, projectId], ) res.json({ documents: rows.map(toDocPayload) }) })) api.post('/documents', safe(async (req, res) => { - const { userId, companyId } = await requireCompany(req) - const projectId = await companyArtifactBucket(companyId) + const { userId, companyId, projectId } = await requireCompanyArtifactContext(req, true) const body = (req.body ?? {}) as { title?: unknown; conversationId?: unknown } const title = typeof body.title === 'string' && body.title.trim() ? body.title.trim().slice(0, 200) @@ -6477,8 +7390,8 @@ api.post('/documents', safe(async (req, res) => { let conversationId: string | null = null if (typeof body.conversationId === 'string' && body.conversationId) { const { rows: convRows } = await pool.query( - `SELECT 1 FROM conversations WHERE id = $1 AND company_id = $2 LIMIT 1`, - [body.conversationId, companyId], + `SELECT 1 FROM conversations WHERE id = $1 AND company_id = $2 AND project_id = $3 LIMIT 1`, + [body.conversationId, companyId, projectId], ) if (convRows.length === 0) throw new HttpError(404, 'conversation not found') conversationId = body.conversationId @@ -6494,24 +7407,24 @@ api.post('/documents', safe(async (req, res) => { FROM documents WHERE id = $1`, [id], ) const doc = toDocPayload(rows[0]) - await publishDocumentChanged(companyId, id, 'document.created', userId) + await publishDocumentChanged(companyId, id, 'document.created', userId, projectId) res.status(201).json(doc) })) api.get('/documents/:id', safe(async (req, res) => { - const { companyId } = await requireCompany(req) + const { companyId, projectId } = await requireCompanyArtifactContext(req) const id = String(req.params.id) const { rows } = await pool.query( `SELECT id, company_id, title, created_by, conversation_id, created_at, updated_at - FROM documents WHERE id = $1 AND company_id = $2`, - [id, companyId], + FROM documents WHERE id = $1 AND company_id = $2 AND project_id = $3`, + [id, companyId, projectId], ) if (!rows[0]) throw new HttpError(404, 'not found') res.json(toDocPayload(rows[0])) })) api.put('/documents/:id', safe(async (req, res) => { - const { userId, companyId } = await requireCompany(req) + const { userId, companyId, projectId } = await requireCompanyArtifactContext(req, true) const id = String(req.params.id) const body = (req.body ?? {}) as { title?: unknown } if (typeof body.title !== 'string' || !body.title.trim()) { @@ -6520,22 +7433,22 @@ api.put('/documents/:id', safe(async (req, res) => { const title = body.title.trim().slice(0, 200) const { rowCount } = await pool.query( `UPDATE documents SET title = $1, updated_at = NOW() - WHERE id = $2 AND company_id = $3`, - [title, id, companyId], + WHERE id = $2 AND company_id = $3 AND project_id = $4`, + [title, id, companyId, projectId], ) if (!rowCount) throw new HttpError(404, 'not found') - await publishDocumentChanged(companyId, id, 'document.updated', userId) + await publishDocumentChanged(companyId, id, 'document.updated', userId, projectId) res.json({ ok: true, title }) })) api.delete('/documents/:id', safe(async (req, res) => { - const { userId, companyId } = await requireCompany(req) + const { userId, companyId, projectId } = await requireCompanyArtifactContext(req, true) const id = String(req.params.id) // Only the creator (or an owner/admin) can delete. Mirrors the // delete-your-own-agent pattern elsewhere in this router. const { rows } = await pool.query<{ created_by: string }>( - `SELECT created_by FROM documents WHERE id = $1 AND company_id = $2`, - [id, companyId], + `SELECT created_by FROM documents WHERE id = $1 AND company_id = $2 AND project_id = $3`, + [id, companyId, projectId], ) if (!rows[0]) throw new HttpError(404, 'not found') if (rows[0].created_by !== userId) { @@ -6547,7 +7460,7 @@ api.delete('/documents/:id', safe(async (req, res) => { if (!PRIVILEGED_ROLES.has(role)) throw new HttpError(403, 'only the creator or an owner can delete') } await pool.query(`DELETE FROM documents WHERE id = $1`, [id]) - await publishDocumentChanged(companyId, id, 'document.deleted', userId) + await publishDocumentChanged(companyId, id, 'document.deleted', userId, projectId) res.json({ ok: true }) })) diff --git a/server/src/calendar.ts b/server/src/calendar.ts index 1cdc844d..7a7b89d4 100644 --- a/server/src/calendar.ts +++ b/server/src/calendar.ts @@ -46,6 +46,7 @@ export interface RecurrenceRule { export interface CalendarEventRow { id: string company_id: string + project_id: string created_by: string kind: 'personal' | 'agent_task' title: string @@ -386,6 +387,7 @@ async function sendReminder(event: CalendarEventRow, occurrence: Date, now: Date await publish(CH_CALENDAR_REMINDER, { type: 'calendar.reminder', companyId: event.company_id, + workspaceId: event.project_id, eventId: event.id, title: event.title, occurrenceAt: occurrence.toISOString(), @@ -498,7 +500,7 @@ export async function tickCalendar(now: Date = new Date()): Promise<{ scanned: n // considered before their start_at, and "active" keeps the working set // small anyway. const { rows } = await pool.query( - `SELECT id, company_id, created_by, kind, title, description, assignee_id, + `SELECT id,company_id,project_id,created_by,kind,title,description,assignee_id, target_conversation_id, agent_prompt, start_at, end_at, all_day, recurrence, status, last_fired_at, reminder_minutes_before, reminder_channel, diff --git a/server/src/canvas/service.ts b/server/src/canvas/service.ts index f987f391..2de27489 100644 --- a/server/src/canvas/service.ts +++ b/server/src/canvas/service.ts @@ -207,13 +207,14 @@ function toFrame(row: FrameRow): CanvasFrame { } async function publishCanvas(companyId: string, event: Omit): Promise { - const { rows } = await pool.query<{ conversation_id: string | null }>( - `SELECT conversation_id FROM canvases WHERE id=$1 AND company_id=$2`, + const { rows } = await pool.query<{ conversation_id: string | null; project_id: string | null }>( + `SELECT conversation_id,project_id FROM canvases WHERE id=$1 AND company_id=$2`, [event.canvasId, companyId], ) await publish(CH_CANVAS, { type: 'canvas.changed', companyId, ...(rows[0]?.conversation_id ? { conversationId: rows[0].conversation_id } : {}), + ...(rows[0]?.project_id ? { workspaceId: rows[0].project_id } : {}), timestamp: new Date().toISOString(), ...event, }) } diff --git a/server/src/db/migrate.ts b/server/src/db/migrate.ts index 33b7634c..e63b89f8 100644 --- a/server/src/db/migrate.ts +++ b/server/src/db/migrate.ts @@ -2434,6 +2434,75 @@ CREATE INDEX IF NOT EXISTS idx_boards_project ON boards(project_id, updated_at D CREATE INDEX IF NOT EXISTS idx_calendar_project ON calendar_events(project_id, start_at); CREATE INDEX IF NOT EXISTS idx_canvases_project ON canvases(project_id, updated_at DESC); +-- ============== Courses and course-scoped authorization ================= +-- A course owns exactly one non-General Project. Project remains the +-- storage/knowledge boundary; these tables add human authorization and +-- invitation semantics without introducing a second artifact scope. +ALTER TABLE companies ADD COLUMN IF NOT EXISTS description TEXT NOT NULL DEFAULT ''; +CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_id_company ON projects(id, company_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_conversations_id_company ON conversations(id, company_id); + +CREATE TABLE IF NOT EXISTS courses ( + id TEXT PRIMARY KEY, + company_id TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE, + project_id TEXT NOT NULL UNIQUE, + created_by TEXT NOT NULL, + study_room_conversation_id TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + UNIQUE (id, company_id), + FOREIGN KEY (project_id, company_id) REFERENCES projects(id, company_id) ON DELETE CASCADE, + FOREIGN KEY (study_room_conversation_id, company_id) REFERENCES conversations(id, company_id) +); +CREATE INDEX IF NOT EXISTS idx_courses_company ON courses(company_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS course_members ( + course_id TEXT NOT NULL, + company_id TEXT NOT NULL, + user_id TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('teacher', 'learner')), + joined_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + PRIMARY KEY (course_id, user_id), + FOREIGN KEY (course_id, company_id) REFERENCES courses(id, company_id) ON DELETE CASCADE, + FOREIGN KEY (company_id, user_id) REFERENCES company_members(company_id, user_id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_course_members_user ON course_members(company_id, user_id, role); + +CREATE TABLE IF NOT EXISTS course_invitations ( + token_hash TEXT PRIMARY KEY, + course_id TEXT NOT NULL, + company_id TEXT NOT NULL, + invited_by TEXT NOT NULL, + email TEXT, + role TEXT NOT NULL CHECK (role IN ('teacher', 'learner')), + note TEXT, + max_uses INTEGER NOT NULL CHECK (max_uses BETWEEN 1 AND 100), + use_count INTEGER NOT NULL DEFAULT 0 CHECK (use_count BETWEEN 0 AND max_uses), + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + revoked_at TIMESTAMP WITH TIME ZONE, + last_accepted_at TIMESTAMP WITH TIME ZONE, + last_accepted_by TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + FOREIGN KEY (course_id, company_id) REFERENCES courses(id, company_id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_course_invitations_course ON course_invitations(course_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_course_invitations_email ON course_invitations(email) WHERE email IS NOT NULL; + +CREATE TABLE IF NOT EXISTS course_invitation_acceptances ( + token_hash TEXT NOT NULL REFERENCES course_invitations(token_hash) ON DELETE CASCADE, + user_id TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('teacher', 'learner')), + accepted_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + PRIMARY KEY (token_hash, user_id) +); + +CREATE TABLE IF NOT EXISTS course_schema_cutovers ( + id TEXT PRIMARY KEY, + completed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + detail JSONB NOT NULL DEFAULT '{}'::jsonb +); + CREATE OR REPLACE FUNCTION touch_knowledge_workspace_updated_at() RETURNS trigger AS $$ BEGIN UPDATE projects SET updated_at = NOW() WHERE id = COALESCE(NEW.project_id, OLD.project_id); @@ -2590,6 +2659,134 @@ async function runAgentOSCutover(client: import('pg').PoolClient): Promise } } +/** Convert the non-General Projects that pre-date the Course model exactly + * once. Only courses inserted by this cutover receive the legacy company's + * full membership. That distinction matters: a later boot must never restore + * a course member that a teacher deliberately removed. */ +async function runLegacyCourseCutover(client: import('pg').PoolClient): Promise { + await client.query('BEGIN') + try { + const { rows: marker } = await client.query<{ done: boolean }>( + `SELECT EXISTS (SELECT 1 FROM course_schema_cutovers WHERE id='course-model-v1') AS done`, + ) + if (marker[0]?.done) { + await client.query('COMMIT') + return + } + + await client.query(` + CREATE TEMP TABLE course_cutover_new_courses ( + id TEXT PRIMARY KEY, + company_id TEXT NOT NULL, + project_id TEXT NOT NULL, + created_by TEXT NOT NULL + ) ON COMMIT DROP + `) + await client.query(` + WITH inserted AS ( + INSERT INTO courses (id, company_id, project_id, created_by) + SELECT 'course-' || substr(md5(project.id), 1, 20), project.company_id, project.id, + COALESCE(project_creator.user_id, owner_member.user_id, company.owner_user_id) + FROM projects project + JOIN companies company ON company.id=project.company_id + LEFT JOIN LATERAL ( + SELECT member.user_id FROM company_members member + WHERE member.company_id=project.company_id AND member.role='owner' + ORDER BY member.joined_at ASC LIMIT 1 + ) owner_member ON TRUE + LEFT JOIN company_members project_creator + ON project_creator.company_id=project.company_id + AND project_creator.user_id=project.created_by + WHERE project.is_general=FALSE + ON CONFLICT (project_id) DO NOTHING + RETURNING id,company_id,project_id,created_by + ) + INSERT INTO course_cutover_new_courses (id,company_id,project_id,created_by) + SELECT id,company_id,project_id,created_by FROM inserted + `) + await client.query(` + INSERT INTO course_members (course_id,company_id,user_id,role) + SELECT course.id,course.company_id,member.user_id, + CASE WHEN member.user_id=course.created_by THEN 'teacher' ELSE 'learner' END + FROM course_cutover_new_courses course + JOIN company_members member ON member.company_id=course.company_id + ON CONFLICT (course_id,user_id) DO NOTHING + `) + await client.query(` + INSERT INTO conversations ( + id,kind,title,subtitle,topic,members,leader_id,pinned,tag,company_id,project_id + ) + SELECT 'course-room-' || substr(md5(course.id), 1, 20),'group', + project.name || ' · Study Room','course','课程学习、讨论、练习与错因诊断', + COALESCE(( + SELECT jsonb_agg(member_id ORDER BY member_order,member_id) + FROM ( + SELECT member.user_id AS member_id,0 AS member_order + FROM course_members member WHERE member.course_id=course.id + UNION + SELECT participant.id,1 + FROM participants participant + WHERE participant.company_id=course.company_id + AND participant.kind='agent' + AND participant.preset_key IN ('nova','sage','milo','trace') + AND participant.departed_at IS NULL + ) room_members + ),'[]'::jsonb), + (SELECT participant.id FROM participants participant + WHERE participant.company_id=course.company_id + AND participant.kind='agent' AND participant.preset_key='nova' + AND participant.departed_at IS NULL LIMIT 1), + TRUE,'course',course.company_id,course.project_id + FROM course_cutover_new_courses course + JOIN projects project ON project.id=course.project_id + ON CONFLICT (id) DO NOTHING + `) + await client.query(` + INSERT INTO conversation_counters (conversation_id,next_sequence) + SELECT room.id,1 + FROM course_cutover_new_courses course + JOIN conversations room + ON room.id='course-room-' || substr(md5(course.id), 1, 20) + ON CONFLICT (conversation_id) DO NOTHING + `) + await client.query(` + INSERT INTO im_channel_bindings (channel_id,company_id,profile,leader_agent_id) + SELECT room.id,room.company_id, + jsonb_build_object( + 'channelId',room.id,'channelType',2,'kind','group', + 'title',room.title,'topic',room.topic,'members',room.members, + 'pinned',TRUE,'createdAt',room.created_at + ),room.leader_id + FROM course_cutover_new_courses course + JOIN conversations room + ON room.id='course-room-' || substr(md5(course.id), 1, 20) + ON CONFLICT (channel_id) DO UPDATE SET + company_id=EXCLUDED.company_id, + profile=EXCLUDED.profile, + leader_agent_id=EXCLUDED.leader_agent_id + `) + await client.query(` + UPDATE courses course + SET study_room_conversation_id=room.id + FROM course_cutover_new_courses migrated + JOIN conversations room + ON room.id='course-room-' || substr(md5(migrated.id), 1, 20) + WHERE course.id=migrated.id + AND course.study_room_conversation_id IS DISTINCT FROM room.id + `) + await client.query(` + INSERT INTO course_schema_cutovers (id,detail) + VALUES ('course-model-v1',jsonb_build_object( + 'migratedCourses',(SELECT COUNT(*) FROM course_cutover_new_courses) + )) + `) + await client.query('COMMIT') + } catch (error) { + await client.query('ROLLBACK').catch(() => undefined) + throw error + } +} + /** * Boot-time wrapper around `ensureSchema` that retries with exponential * backoff when the DB is briefly unreachable. Without this, a single @@ -2938,6 +3135,10 @@ export async function ensureSchema(): Promise { throw e } } + // This data cutover is deliberately outside the DDL skip branch. On a + // busy, already-shaped database the DDL may be skipped after lock + // contention, but the persistent cutover marker still has to be applied. + await runLegacyCourseCutover(client) } finally { // Release on the same connection the lock was taken on. // `pool.release(client)` below would do it implicitly via @@ -3002,6 +3203,10 @@ async function schemaAlreadyCurrent(client: import('pg').PoolClient): Promise 0 AND (SELECT count(*) FROM information_schema.columns WHERE table_name = 'projects' AND column_name = 'is_general') > 0 + AND (SELECT count(*) FROM pg_class WHERE relname = 'courses') > 0 + AND (SELECT count(*) FROM pg_class WHERE relname = 'course_members') > 0 + AND (SELECT count(*) FROM pg_class WHERE relname = 'course_invitations') > 0 + AND (SELECT count(*) FROM pg_class WHERE relname = 'course_schema_cutovers') > 0 AS ok `) return rows[0]?.ok === true diff --git a/server/src/documents/rooms.ts b/server/src/documents/rooms.ts index af694b52..73372f23 100644 --- a/server/src/documents/rooms.ts +++ b/server/src/documents/rooms.ts @@ -246,6 +246,7 @@ export function unsubscribe(documentId: string, sub: DocSubscriber): void { evictions.delete(documentId) } }, ROOM_GRACE_MS) + t.unref() evictions.set(documentId, t) } } diff --git a/server/src/oauth.ts b/server/src/oauth.ts index 5a68285f..b96ae543 100644 --- a/server/src/oauth.ts +++ b/server/src/oauth.ts @@ -109,6 +109,8 @@ interface StateData { * step for net-new users (they don't want their own workspace — they want * to land in the inviter's). */ inviteToken: string | null + /** Distinguishes organization and course invitations across the OAuth round trip. */ + inviteKind: 'company' | 'course' | null } /** Validate a client-supplied return URL against the configured allow-list. @@ -128,10 +130,11 @@ export async function createState( p: Provider, returnUrl: string | null, inviteToken: string | null = null, + inviteKind: 'company' | 'course' | null = null, ): Promise { const state = randomBytes(32).toString('base64url') const key = `oauth:state:${hashState(state)}` - const data: StateData = { provider: p, returnUrl, inviteToken } + const data: StateData = { provider: p, returnUrl, inviteToken, inviteKind } await redis.set(key, JSON.stringify(data), 'EX', 300) return state } diff --git a/server/src/redis.ts b/server/src/redis.ts index f263798d..37c7ccb5 100644 --- a/server/src/redis.ts +++ b/server/src/redis.ts @@ -49,6 +49,8 @@ export const CH_CANVAS = 'lingxiloop:canvas' * fan-out can echo-suppress on the sender's own socket. */ export const CH_DOC_UPDATE = 'lingxiloop:doc.update' export const CH_DOC_AWARENESS = 'lingxiloop:doc.awareness' +/** Cross-instance revocation for already-open collaborative document rooms. */ +export const CH_DOC_ACCESS_REVOKED = 'lingxiloop:doc.access.revoked' /** A user / agent was @-mentioned inside a doc. Fanned out via the * generic tenant-scoped WS bridge (NOT the per-doc subscription * bridge) — recipients listen by their participant id, regardless of @@ -239,6 +241,7 @@ export interface ConversationUpdatedEvent extends TenantTagged { conversationId: string /** what changed (so clients can patch surgically instead of refetching) */ patch: { topic?: string | null; title?: string; leaderId?: string | null } + workspaceId?: string } export interface GroupPulledEvent extends TenantTagged { @@ -281,6 +284,7 @@ export interface BoardEvent extends TenantTagged { mentions?: string[] /** Actor who triggered the change — used to suppress self-notifications. */ actorId?: string + workspaceId?: string } /** Document metadata/listing changed. Content sync still uses the CRDT @@ -291,6 +295,7 @@ export interface DocIndexEvent extends TenantTagged { kind: 'document.created' | 'document.updated' | 'document.deleted' documentId: string actorId?: string + workspaceId?: string } export interface CanvasEvent extends TenantTagged { @@ -304,6 +309,7 @@ export interface CanvasEvent extends TenantTagged { canvasId: string timestamp: string conversationId?: string + workspaceId?: string revision?: number frameId?: string participantId?: string @@ -352,6 +358,7 @@ export interface DocMentionEvent extends TenantTagged { mentionerId: string mentionerName: string mentionedIds: string[] + workspaceId?: string } /** "Heads-up — this calendar event fires in N minutes." Broadcast on @@ -374,6 +381,7 @@ export interface CalendarReminderEvent extends TenantTagged { /** Surfaces in the toast subtitle. */ kind: 'personal' | 'agent_task' assigneeId: string | null + workspaceId?: string } /** A calendar row was created / updated / deleted. We deliberately keep @@ -393,6 +401,7 @@ export interface CalendarEventChangedEvent extends TenantTagged { * for renderers that want to avoid echoing the actor's own * optimistic write back at them. */ actorId: string | null + workspaceId?: string } /** Poll state changed — a new vote was cast, an existing vote was changed, @@ -441,12 +450,18 @@ export interface AgentActivityEvent extends TenantTagged { } } +export interface DocAccessRevokedEvent extends TenantTagged { + type: 'doc.access.revoked' + userId: string +} + export type BroadcastEvent = MessageNewEvent | MessageDeltaEvent | TypingEvent | StatusEvent | AvatarEvent | ParticipantAddedEvent | ReactionsEvent | GroupPulledEvent | ConversationUpdatedEvent | ConveneEvent | BoardEvent | DocIndexEvent | CanvasEvent | DocUpdateEvent | DocAwarenessEvent | DocMentionEvent | CalendarReminderEvent | CalendarEventChangedEvent | PollUpdatedEvent + | DocAccessRevokedEvent | AgentActivityEvent export async function publish(channel: string, event: BroadcastEvent): Promise { diff --git a/server/src/ws.ts b/server/src/ws.ts index 9b6402b5..21995209 100644 --- a/server/src/ws.ts +++ b/server/src/ws.ts @@ -4,7 +4,7 @@ import { sub, CH_STATUS, CH_GROUP_PULLED, CH_CONVO_UPDATED, CH_CONVENE, - CH_BOARDS, CH_DOCS, CH_CANVAS, CH_CALENDAR_REMINDER, CH_CALENDAR_EVENTS, CH_DOC_MENTION, CH_AGENT_ACTIVITY, + CH_BOARDS, CH_DOCS, CH_DOC_ACCESS_REVOKED, CH_CANVAS, CH_CALENDAR_REMINDER, CH_CALENDAR_EVENTS, CH_DOC_MENTION, CH_AGENT_ACTIVITY, publish, type DocMentionEvent, } from './redis.js' @@ -45,6 +45,39 @@ interface AuthedSocket { const clients = new Set() +/** Force clients to refresh membership state after an administrator removes a + * user. A reconnect obtains a fresh ticket and company set, so stale sockets + * cannot keep receiving General workspace events from the removed company. */ +export function disconnectUserFromCompany(userId: string, companyId: string): void { + for (const client of clients) { + if (client.userId !== userId || !client.companies.has(companyId)) continue + client.companies.delete(companyId) + try { client.ws.close(4403, 'company membership removed') } catch { /* best effort */ } + } +} + +/** Revoke live collaborative-document subscriptions after a course member is + * removed. The socket remains connected for the user's other workspaces, but + * every room in the removed Project is detached before the API confirms the + * removal, so an already-open tab cannot keep receiving document updates. */ +export async function revokeUserProjectDocumentSubscriptions(userId: string, projectId: string): Promise { + const { rows } = await pool.query<{ id: string }>( + `SELECT id FROM documents WHERE project_id=$1`, + [projectId], + ) + if (rows.length === 0) return + const projectDocuments = new Set(rows.map((row) => row.id)) + for (const client of clients) { + if (client.userId !== userId) continue + for (const documentId of projectDocuments) { + const subscriber = client.docSubs.get(documentId) + if (!subscriber) continue + docUnsubscribe(documentId, subscriber) + client.docSubs.delete(documentId) + } + } +} + // Per-client WebSocket send backpressure caps (OOM fix). A socket that can't // drain makes `ws` buffer unsent frames in process memory; without a cap, a high // broadcast rate grows that buffer unbounded across clients until the pod OOMs. @@ -142,14 +175,20 @@ async function loadMemberships(userId: string): Promise> { /** Look up a doc + verify the caller's tenant membership in one shot. * Returns null when the doc doesn't exist OR the caller can't see it — * same opaque posture the chat handlers use to avoid leaking existence. */ -async function docCompanyFor(documentId: string, userId: string): Promise { +async function docCompanyFor(documentId: string, userId: string, writable = false): Promise { const { rows } = await pool.query<{ company_id: string }>( `SELECT d.company_id FROM documents d + JOIN projects project ON project.id=d.project_id JOIN company_members m ON m.company_id = d.company_id AND m.user_id = $2 + LEFT JOIN courses course ON course.project_id=project.id + LEFT JOIN course_members course_member + ON course_member.course_id=course.id AND course_member.user_id=$2 WHERE d.id = $1 + AND (project.is_general=TRUE OR m.role IN ('owner','admin') OR course_member.user_id IS NOT NULL) + AND ($3::boolean=FALSE OR project.status='active') LIMIT 1`, - [documentId, userId], + [documentId, userId, writable], ) return rows[0]?.company_id ?? null } @@ -214,7 +253,7 @@ async function handleDocFrame(c: AuthedSocket, msg: Record): Pr if (!subRec) return // must subscribe first const updateB64 = typeof msg.updateB64 === 'string' ? msg.updateB64 : '' if (!updateB64) return - const companyId = await docCompanyFor(documentId, c.userId) + const companyId = await docCompanyFor(documentId, c.userId, true) if (!companyId) return const buf = Buffer.from(updateB64, 'base64') const update = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength) @@ -267,9 +306,16 @@ async function processDocMention(args: { // Resolve the mentioned ids that actually belong to this tenant. // Match against `participants` (covers both humans + agents). const { rows: validRows } = await pool.query<{ id: string; kind: string; name: string }>( - `SELECT id, kind, name FROM participants - WHERE company_id = $1 AND id = ANY($2::text[])`, - [companyId, requestedIds], + `SELECT participant.id,participant.kind,participant.name + FROM participants participant + JOIN documents document ON document.id=$3 AND document.company_id=participant.company_id + JOIN projects project ON project.id=document.project_id + LEFT JOIN courses course ON course.project_id=project.id + LEFT JOIN course_members course_member + ON course_member.course_id=course.id AND course_member.user_id=participant.id + WHERE participant.company_id=$1 AND participant.id=ANY($2::text[]) + AND (participant.kind='agent' OR project.is_general=TRUE OR course_member.user_id IS NOT NULL)`, + [companyId, requestedIds, documentId], ) if (validRows.length === 0) return @@ -278,8 +324,8 @@ async function processDocMention(args: { // surface for the agent-wake chat ping; falling back to a 1:1 DM // when the doc isn't pinned avoids dragging unrelated members into // a chat noise loop. - const { rows: docRows } = await pool.query<{ title: string; conversation_id: string | null }>( - `SELECT title, conversation_id FROM documents WHERE id = $1 AND company_id = $2`, + const { rows: docRows } = await pool.query<{ title: string; conversation_id: string | null; project_id: string }>( + `SELECT title,conversation_id,project_id FROM documents WHERE id=$1 AND company_id=$2`, [documentId, companyId], ) const documentTitle = docRows[0]?.title ?? 'Untitled' @@ -353,6 +399,7 @@ async function processDocMention(args: { mentionerId, mentionerName, mentionedIds: freshIds, + workspaceId: docRows[0]?.project_id, } await publish(CH_DOC_MENTION, event) } @@ -543,21 +590,33 @@ export function attachWebSocket(httpServer: Server) { sub.subscribe( CH_STATUS, CH_GROUP_PULLED, CH_CONVO_UPDATED, CH_CONVENE, - CH_BOARDS, CH_DOCS, CH_CANVAS, CH_CALENDAR_REMINDER, CH_CALENDAR_EVENTS, CH_DOC_MENTION, CH_AGENT_ACTIVITY, + CH_BOARDS, CH_DOCS, CH_DOC_ACCESS_REVOKED, CH_CANVAS, CH_CALENDAR_REMINDER, CH_CALENDAR_EVENTS, CH_DOC_MENTION, CH_AGENT_ACTIVITY, ).then((count) => { console.log(`[ws] subscribed to ${count} redis channels`) }) sub.on('message', (channel, payload) => { + void (async () => { // Doc channels are room-scoped, not company-scoped — skip them here. if (channel === 'lingxiloop:doc.update' || channel === 'lingxiloop:doc.awareness') return + if (channel === CH_DOC_ACCESS_REVOKED) { + try { + const event = JSON.parse(payload) as { userId?: string; workspaceId?: string } + if (event.userId && event.workspaceId) { + await revokeUserProjectDocumentSubscriptions(event.userId, event.workspaceId) + } + } catch { /* malformed — drop */ } + return + } // Tenant-aware fan-out: only deliver an event to a socket if the event's // companyId is in the socket's set of memberships. Untagged events are // dropped (no leakage), since every publisher is expected to tag. let companyId: string | undefined + let workspaceId: string | undefined try { - const parsed = JSON.parse(payload) as { companyId?: string } + const parsed = JSON.parse(payload) as { companyId?: string; workspaceId?: string } if (typeof parsed.companyId === 'string') companyId = parsed.companyId + if (typeof parsed.workspaceId === 'string') workspaceId = parsed.workspaceId } catch { /* malformed — drop */ return } if (!companyId) { @@ -568,8 +627,25 @@ export function attachWebSocket(httpServer: Server) { return } + let projectViewers: Set | null = null + if (workspaceId) { + const { rows } = await pool.query<{ user_id: string }>( + `SELECT company_member.user_id + FROM projects project + JOIN company_members company_member ON company_member.company_id=project.company_id + LEFT JOIN courses course ON course.project_id=project.id + LEFT JOIN course_members course_member + ON course_member.course_id=course.id AND course_member.user_id=company_member.user_id + WHERE project.id=$1 AND project.company_id=$2 + AND (project.is_general=TRUE OR company_member.role IN ('owner','admin') OR course_member.user_id IS NOT NULL)`, + [workspaceId, companyId], + ) + projectViewers = new Set(rows.map((row) => row.user_id)) + } + for (const c of clients) { if (!c.companies.has(companyId)) continue + if (projectViewers && !projectViewers.has(c.userId)) continue if (c.ws.readyState !== c.ws.OPEN) continue // Backpressure guard (OOM fix): `ws.send()` buffers unsent frames in // process memory when a socket can't drain (slow/stuck client). Under a @@ -586,6 +662,7 @@ export function attachWebSocket(httpServer: Server) { if (buffered > WS_MAX_BUFFERED_BYTES) continue // skip frame; let it drain try { c.ws.send(payload) } catch { /* ignore */ } } + })().catch((error) => console.warn('[ws] event fan-out failed', error)) }) // Heartbeat sweeper. Real-deal human presence used to drift because TCP diff --git a/src/__tests__/course-contract.test.ts b/src/__tests__/course-contract.test.ts new file mode 100644 index 00000000..ef4f30ed --- /dev/null +++ b/src/__tests__/course-contract.test.ts @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +test('production and mock courses share one explicit normalized contract', async () => { + const { normalizeCourseContract } = await import('../api/courseContract.js') + const normalized = normalizeCourseContract({ + id: 'course-contract', companyId: 'company-contract', projectId: 'project-contract', + name: 'Contract course', courseRole: 'teacher', canManage: true, + memberCount: 8, studyRoomId: 'room-contract', + }) + assert.deepEqual(normalized, { + id: 'course-contract', companyId: 'company-contract', projectId: 'project-contract', + name: 'Contract course', description: '', color: '#5266d6', status: 'active', + createdBy: 'mock-user', studyRoomId: 'room-contract', companyRole: undefined, + courseRole: 'teacher', memberCount: 8, canManage: true, createdAt: undefined, updatedAt: undefined, + }) +}) diff --git a/src/api/client.ts b/src/api/client.ts index bf150ef6..258b92dc 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,4 +1,8 @@ import { getActiveCompanyId, getAuthToken, useAuth } from '@/stores/auth' +import { getWorkspaceSession } from '@/lib/workspaceSession' +import { isMockImDevelopment } from '@/lib/devMode' +import { normalizeCourseContract } from './courseContract' +export { normalizeCourseContract } from './courseContract' import type { AgentCapability, BoardCardComment, @@ -105,6 +109,8 @@ export async function http(path: string, init?: RequestInit): Promise { if (token) headers.authorization = `Bearer ${token}` const company = getActiveCompanyId() if (company) headers['x-company-id'] = company + const workspace = getWorkspaceSession() + if (workspace && workspace.companyId === company) headers['x-project-id'] = workspace.projectId if (getDevModeEnabled()) headers['x-lingxiloop-dev-mode'] = '1' const res = await fetch(`${API}${path}`, { headers: { ...headers, ...(init?.headers ?? {}) }, @@ -614,6 +620,139 @@ export interface ApiInvitationAccept { company: { id: string; name: string; slug: string; role: string } } +export interface ApiCompanyProfile { + id: string + name: string + slug: string + description: string + role: 'owner' | 'admin' | 'member' + createdAt: string +} + +export interface ApiCompanyMember { + id: string + name: string + email: string + role: 'owner' | 'admin' | 'member' + joinedAt: string + courses: Array<{ courseId: string; name: string; role: 'teacher' | 'learner' }> +} + +export interface ApiCourse { + id: string + companyId: string + projectId: string + name: string + description: string + color: string + status: 'active' | 'archived' + createdBy: string + studyRoomId: string | null + companyRole?: 'owner' | 'admin' | 'member' + courseRole: 'teacher' | 'learner' | null + memberCount: number + canManage: boolean + createdAt?: string + updatedAt?: string +} + +export interface ApiCourseMember { + id: string + name: string + email: string + role: 'teacher' | 'learner' + joinedAt: string +} + +export interface ApiCourseInvitation { + id: string + email: string | null + role: 'teacher' | 'learner' + note: string | null + maxUses: number + useCount: number + createdAt: string + expiresAt: string + revokedAt?: string | null + lastAcceptedAt?: string | null + lastAcceptedBy?: string | null + acceptances?: Array<{ userId: string; name: string | null; role: 'teacher' | 'learner'; acceptedAt: string }> + status: 'active' | 'revoked' | 'expired' | 'consumed' +} + +export interface ApiCourseInvitationWithToken extends ApiCourseInvitation { + token: string + url: string +} + +export interface ApiCourseInvitationPreview { + kind: 'course' + status: ApiInvitationPreviewStatus | 'archived' + invitation?: { + role: 'teacher' | 'learner' + email: string | null + note: string | null + expiresAt: string + inviterName: string | null + company: { id: string; name: string; slug: string } + course: { id: string; name: string; projectId: string; studyRoomId: string | null } + } +} + +export interface ApiCourseInvitationAccept { + ok: true + alreadyMember: boolean + joinedCompany: boolean + company: { id: string; name: string; slug: string; role: string } + course: { id: string; name: string; projectId: string; studyRoomId: string | null; role: 'teacher' | 'learner' } +} + +const MOCK_COMPANY_ID = 'mock-workspace' +const MOCK_NOW = '2026-08-26T00:00:00.000Z' +let MOCK_COURSES: ApiCourse[] = [ + normalizeCourseContract({ id: 'mock-course-ai', companyId: MOCK_COMPANY_ID, projectId: 'mock-research', name: 'AI 产品研究', description: 'Teacher 示例课程', courseRole: 'teacher', companyRole: 'owner', studyRoomId: 'mock-general', memberCount: 3, canManage: true }), + normalizeCourseContract({ id: 'mock-course-design', companyId: MOCK_COMPANY_ID, projectId: 'mock-launch', name: '交互设计基础', description: 'Learner 示例课程', courseRole: 'learner', companyRole: 'owner', studyRoomId: 'mock-launch-room', memberCount: 2, canManage: true, color: '#d97706' }), +] +const MOCK_PROJECTS: ApiProject[] = MOCK_COURSES.map((course) => ({ + id: course.projectId, name: course.name, description: course.description, color: course.color, + status: course.status, createdBy: 'mock-me', isGeneral: false, createdAt: MOCK_NOW, + updatedAt: MOCK_NOW, archivedAt: null, lastVisitedAt: MOCK_NOW, sourceCount: 2, + conversationCount: 2, documentCount: 1, boardCount: 1, calendarEventCount: 1, + canvasCount: 1, canManage: course.canManage, +})) +let MOCK_COMPANY: ApiCompanyProfile = { + id: MOCK_COMPANY_ID, name: 'LingxiLoop 本地工作区', slug: 'local', + description: '用于验证 Company 与多课程管理的开发数据。', role: 'owner', createdAt: MOCK_NOW, +} +const MOCK_COMPANY_MEMBERS: ApiCompanyMember[] = [ + { id: 'mock-me', name: '林曦', email: 'dev@localhost', role: 'owner', joinedAt: MOCK_NOW, courses: [ + { courseId: 'mock-course-ai', name: 'AI 产品研究', role: 'teacher' }, + { courseId: 'mock-course-design', name: '交互设计基础', role: 'learner' }, + ] }, + { id: 'mock-teacher', name: '陈老师', email: 'teacher@example.com', role: 'member', joinedAt: MOCK_NOW, courses: [ + { courseId: 'mock-course-ai', name: 'AI 产品研究', role: 'teacher' }, + ] }, + { id: 'mock-learner', name: '李同学', email: 'learner@example.com', role: 'member', joinedAt: MOCK_NOW, courses: [ + { courseId: 'mock-course-ai', name: 'AI 产品研究', role: 'learner' }, + { courseId: 'mock-course-design', name: '交互设计基础', role: 'learner' }, + ] }, +] +const MOCK_COURSE_MEMBERS: Record = { + 'mock-course-ai': [ + { id: 'mock-me', name: '林曦', email: 'dev@localhost', role: 'teacher', joinedAt: MOCK_NOW }, + { id: 'mock-teacher', name: '陈老师', email: 'teacher@example.com', role: 'teacher', joinedAt: MOCK_NOW }, + { id: 'mock-learner', name: '李同学', email: 'learner@example.com', role: 'learner', joinedAt: MOCK_NOW }, + ], + 'mock-course-design': [ + { id: 'mock-me', name: '林曦', email: 'dev@localhost', role: 'learner', joinedAt: MOCK_NOW }, + { id: 'mock-teacher', name: '陈老师', email: 'teacher@example.com', role: 'teacher', joinedAt: MOCK_NOW }, + ], +} +const MOCK_COURSE_INVITATIONS: Record = { + 'mock-course-ai': [{ id: 'mock-invite', email: null, role: 'learner', note: '班级公开链接', maxUses: 30, useCount: 4, createdAt: MOCK_NOW, expiresAt: '2026-09-02T00:00:00.000Z', status: 'active' }], + 'mock-course-design': [], +} + export type ShippingFeatureStatus = | 'draft' | 'contract' | 'building' | 'verifying' | 'ready' | 'releasing' | 'watching' | 'learned' | 'paused' | 'archived' @@ -754,10 +893,11 @@ export const api = { * `window.location.assign(api.authStartUrl('lingxi'))` rather than * fetch — the browser needs to do the actual navigation so the * callback can land back on AUTH_DONE_URL with the session token. */ - authStartUrl: (provider: 'lingxi', opts?: { inviteToken?: string | null; returnUrl?: string | null }) => { + authStartUrl: (provider: 'lingxi', opts?: { inviteToken?: string | null; inviteKind?: 'company' | 'course' | null; returnUrl?: string | null }) => { const params = new URLSearchParams() if (opts?.returnUrl) params.set('return', opts.returnUrl) if (opts?.inviteToken) params.set('invite', opts.inviteToken) + if (opts?.inviteKind) params.set('inviteKind', opts.inviteKind) const qs = params.toString() return `${API}/auth/start/${provider}${qs ? `?${qs}` : ''}` }, @@ -780,7 +920,7 @@ export const api = { http('/me/quota'), listCompanies: () => http>('/companies'), - listProjects: () => http('/projects'), + listProjects: () => isMockImDevelopment() ? Promise.resolve(MOCK_PROJECTS) : http('/projects'), openProject: (id: string) => http<{ ok: boolean }>(`/projects/${encodeURIComponent(id)}/open`, { method: 'POST' }), createProject: (input: { name: string; description?: string; color?: string }) => http('/projects', { method: 'POST', body: JSON.stringify(input) }), archiveProject: (id: string, archive = true) => http<{ ok: boolean; status: string }>(`/projects/${encodeURIComponent(id)}/archive`, { method: 'POST', body: JSON.stringify({ archive }) }), @@ -854,6 +994,102 @@ export const api = { http<{ id: string; name: string; slug: string; role: string }>('/companies', { method: 'POST', body: JSON.stringify({ name }), }), + getCompany: (companyId: string) => isMockImDevelopment() + ? Promise.resolve({ ...MOCK_COMPANY, id: companyId }) + : http(`/companies/${encodeURIComponent(companyId)}`), + updateCompany: (companyId: string, input: { name?: string; description?: string }) => { + if (!isMockImDevelopment()) return http(`/companies/${encodeURIComponent(companyId)}`, { method: 'PATCH', body: JSON.stringify(input) }) + MOCK_COMPANY = { ...MOCK_COMPANY, ...input, id: companyId } + return Promise.resolve(MOCK_COMPANY) + }, + listCompanyMembers: (companyId: string) => isMockImDevelopment() + ? Promise.resolve(MOCK_COMPANY_MEMBERS) + : http(`/companies/${encodeURIComponent(companyId)}/members`), + updateCompanyMember: (companyId: string, userId: string, role: 'admin' | 'member') => { + if (!isMockImDevelopment()) return http<{ ok: true; userId: string; role: string }>(`/companies/${encodeURIComponent(companyId)}/members/${encodeURIComponent(userId)}`, { method: 'PATCH', body: JSON.stringify({ role }) }) + const member = MOCK_COMPANY_MEMBERS.find((row) => row.id === userId) + if (member && member.role !== 'owner') member.role = role + return Promise.resolve({ ok: true as const, userId, role }) + }, + removeCompanyMember: (companyId: string, userId: string) => { + if (!isMockImDevelopment()) return http<{ ok: true }>(`/companies/${encodeURIComponent(companyId)}/members/${encodeURIComponent(userId)}`, { method: 'DELETE' }) + const index = MOCK_COMPANY_MEMBERS.findIndex((row) => row.id === userId && row.role !== 'owner') + if (index >= 0) MOCK_COMPANY_MEMBERS.splice(index, 1) + return Promise.resolve({ ok: true as const }) + }, + listCourses: async () => { + if (isMockImDevelopment()) return MOCK_COURSES.map(normalizeCourseContract) + return (await http('/courses')).map(normalizeCourseContract) + }, + getCourse: (courseId: string) => { + if (isMockImDevelopment()) { + const course = MOCK_COURSES.find((row) => row.id === courseId) + return course ? Promise.resolve(course) : Promise.reject(new Error('course not found')) + } + return http(`/courses/${encodeURIComponent(courseId)}`) + }, + createCourse: (input: { name: string; description?: string; color?: string }) => { + if (!isMockImDevelopment()) return http('/courses', { method: 'POST', body: JSON.stringify(input) }) + const suffix = String(Date.now()) + const course = normalizeCourseContract({ + id: `mock-course-${suffix}`, companyId: MOCK_COMPANY_ID, projectId: `mock-project-${suffix}`, + name: input.name, description: input.description ?? '', color: input.color, + createdBy: 'mock-me', companyRole: 'owner', courseRole: 'teacher', + studyRoomId: 'mock-general', memberCount: 1, canManage: true, + }) + MOCK_COURSES = [course, ...MOCK_COURSES] + MOCK_COURSE_MEMBERS[course.id] = [{ id: 'mock-me', name: '林曦', email: 'dev@localhost', role: 'teacher', joinedAt: MOCK_NOW }] + MOCK_COURSE_INVITATIONS[course.id] = [] + return Promise.resolve(course) + }, + updateCourse: (courseId: string, input: { name?: string; description?: string; color?: string }) => + http<{ ok: true }>(`/courses/${encodeURIComponent(courseId)}`, { method: 'PATCH', body: JSON.stringify(input) }), + archiveCourse: (courseId: string, archive = true) => { + if (!isMockImDevelopment()) return http<{ ok: true; status: 'active' | 'archived' }>(`/courses/${encodeURIComponent(courseId)}/archive`, { method: 'POST', body: JSON.stringify({ archive }) }) + const status = archive ? 'archived' : 'active' + MOCK_COURSES = MOCK_COURSES.map((course) => course.id === courseId ? { ...course, status } : course) + return Promise.resolve({ ok: true as const, status }) + }, + listCourseMembers: (courseId: string) => isMockImDevelopment() + ? Promise.resolve(MOCK_COURSE_MEMBERS[courseId] ?? []) + : http(`/courses/${encodeURIComponent(courseId)}/members`), + updateCourseMember: (courseId: string, userId: string, role: 'teacher' | 'learner') => { + if (!isMockImDevelopment()) return http<{ ok: true }>(`/courses/${encodeURIComponent(courseId)}/members/${encodeURIComponent(userId)}`, { method: 'PATCH', body: JSON.stringify({ role }) }) + const member = MOCK_COURSE_MEMBERS[courseId]?.find((row) => row.id === userId) + if (member) member.role = role + return Promise.resolve({ ok: true as const }) + }, + removeCourseMember: (courseId: string, userId: string) => { + if (!isMockImDevelopment()) return http<{ ok: true }>(`/courses/${encodeURIComponent(courseId)}/members/${encodeURIComponent(userId)}`, { method: 'DELETE' }) + MOCK_COURSE_MEMBERS[courseId] = (MOCK_COURSE_MEMBERS[courseId] ?? []).filter((row) => row.id !== userId) + return Promise.resolve({ ok: true as const }) + }, + listCourseInvitations: (courseId: string) => isMockImDevelopment() + ? Promise.resolve(MOCK_COURSE_INVITATIONS[courseId] ?? []) + : http(`/courses/${encodeURIComponent(courseId)}/invitations`), + createCourseInvitation: (courseId: string, input: { email?: string | null; role: 'teacher' | 'learner'; note?: string | null; expiresInDays?: number; maxUses?: number }) => { + if (!isMockImDevelopment()) return http(`/courses/${encodeURIComponent(courseId)}/invitations`, { method: 'POST', body: JSON.stringify(input) }) + const id = `mock-invite-${Date.now()}` + const invitation: ApiCourseInvitationWithToken = { + id, token: id, url: `${location.origin}/invite/course/${id}`, email: input.email ?? null, + role: input.role, note: input.note ?? null, maxUses: input.maxUses ?? 1, useCount: 0, + createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + (input.expiresInDays ?? 7) * 86_400_000).toISOString(), status: 'active', + } + MOCK_COURSE_INVITATIONS[courseId] = [invitation, ...(MOCK_COURSE_INVITATIONS[courseId] ?? [])] + return Promise.resolve(invitation) + }, + revokeCourseInvitation: (courseId: string, invitationId: string) => { + if (!isMockImDevelopment()) return http<{ ok: true; revoked: boolean }>(`/courses/${encodeURIComponent(courseId)}/invitations/${encodeURIComponent(invitationId)}`, { method: 'DELETE' }) + let revoked = false + MOCK_COURSE_INVITATIONS[courseId] = (MOCK_COURSE_INVITATIONS[courseId] ?? []).map((invitation) => { + if (invitation.id !== invitationId) return invitation + revoked = true + return { ...invitation, status: 'revoked' as const, revokedAt: new Date().toISOString() } + }) + return Promise.resolve({ ok: true as const, revoked }) + }, + previewCourseInvitation: (token: string) => http(`/course-invitations/${encodeURIComponent(token)}`), + acceptCourseInvitation: (token: string) => http(`/course-invitations/${encodeURIComponent(token)}/accept`, { method: 'POST', body: '{}' }), /** Owner/admin-only: list every invitation (active + historical) for a * company so the management UI can show recent activity. */ listInvitations: (companyId: string) => diff --git a/src/api/courseContract.ts b/src/api/courseContract.ts new file mode 100644 index 00000000..6f3a1ebb --- /dev/null +++ b/src/api/courseContract.ts @@ -0,0 +1,14 @@ +import type { ApiCourse } from './client' + +/** Single production/mock boundary for the course contract. Keeping coercion + * here prevents development fixtures from silently drifting from API JSON. */ +export function normalizeCourseContract(value: Partial & Pick): ApiCourse { + return { + id: value.id, companyId: value.companyId, projectId: value.projectId, name: value.name, + description: value.description ?? '', color: value.color ?? '#5266d6', + status: value.status === 'archived' ? 'archived' : 'active', createdBy: value.createdBy ?? 'mock-user', + studyRoomId: value.studyRoomId ?? null, companyRole: value.companyRole, + courseRole: value.courseRole ?? null, memberCount: Number(value.memberCount ?? 0), + canManage: Boolean(value.canManage), createdAt: value.createdAt, updatedAt: value.updatedAt, + } +} diff --git a/src/components/InviteAcceptScreen.tsx b/src/components/InviteAcceptScreen.tsx index 488fb898..8843efb5 100644 --- a/src/components/InviteAcceptScreen.tsx +++ b/src/components/InviteAcceptScreen.tsx @@ -27,8 +27,10 @@ * • not_found — bad link. */ import { useCallback, useEffect, useState } from 'react' -import { api, getServerOrigin, type ApiInvitationPreview } from '@/api/client' +import { api, getServerOrigin, type ApiCourseInvitationAccept, type ApiCourseInvitationPreview, type ApiInvitationPreview } from '@/api/client' import { useAuth } from '@/stores/auth' +import { useApp } from '@/stores/app' +import { setWorkspaceSession } from '@/lib/workspaceSession' import { isElectron, isWebAppHost } from '@/lib/runtime' import { CloudLogo } from './Avatar' import { GetDesktopAppLink } from './GetDesktopAppLink' @@ -43,6 +45,14 @@ const INVITE_TOKEN_KEY = 'lingxiloop.pending-invite' * back up on return. */ export function consumeInviteFromUrl(): { token: string; clear: () => void } | null { const url = new URL(window.location.href) + const coursePathMatch = url.pathname.match(/^\/invite\/course\/([^/?#]+)\/?$/) + if (coursePathMatch) { + const token = `course:${decodeURIComponent(coursePathMatch[1])}` + const clear = () => { + try { history.replaceState(null, '', `${url.origin}/${url.search}${url.hash}`) } catch { /* swallow */ } + } + return { token, clear } + } const pathMatch = url.pathname.match(/^\/invite\/([^/?#]+)\/?$/) if (pathMatch) { const token = decodeURIComponent(pathMatch[1]) @@ -103,6 +113,8 @@ interface Props { export function InviteAcceptScreen({ token, onDone }: Props) { const token_ = token + const courseInvite = token_.startsWith('course:') + const rawToken = courseInvite ? token_.slice('course:'.length) : token_ const tokenUserId = useAuth((s) => s.user?.id ?? null) const tokenStr = useAuth((s) => s.token) const setMe = useAuth((s) => s.setMe) @@ -111,7 +123,7 @@ export function InviteAcceptScreen({ token, onDone }: Props) { const companies = useAuth((s) => s.companies) const user = useAuth((s) => s.user) - const [preview, setPreview] = useState(null) + const [preview, setPreview] = useState(null) const [previewErr, setPreviewErr] = useState(null) const [busy, setBusy] = useState(false) const [acceptErr, setAcceptErr] = useState(null) @@ -124,19 +136,19 @@ export function InviteAcceptScreen({ token, onDone }: Props) { const loadPreview = useCallback(async () => { setPreviewErr(null) try { - const r = await api.previewInvitation(token_) + const r = courseInvite ? await api.previewCourseInvitation(rawToken) : await api.previewInvitation(rawToken) setPreview(r) } catch (e) { setPreviewErr(e instanceof Error ? e.message : String(e)) } - }, [token_]) + }, [courseInvite, rawToken]) useEffect(() => { void loadPreview() }, [loadPreview, tokenStr]) const accept = useCallback(async () => { setBusy(true); setAcceptErr(null) try { - const r = await api.acceptInvitation(token_) + const r = courseInvite ? await api.acceptCourseInvitation(rawToken) : await api.acceptInvitation(rawToken) // Refresh /auth/me so the companies list (used by the switcher) gets // the freshly-joined workspace without a manual reload. try { @@ -160,6 +172,11 @@ export function InviteAcceptScreen({ token, onDone }: Props) { } } clearPendingInvite() + if (courseInvite && 'course' in r) { + const accepted = r as ApiCourseInvitationAccept + setWorkspaceSession({ companyId: accepted.company.id, projectId: accepted.course.projectId }) + useApp.getState().selectConversation(accepted.course.studyRoomId) + } // Web and native clients both enter the workspace immediately. The Web // app is a complete product surface, not a desktop-download handoff. onDone() @@ -168,7 +185,7 @@ export function InviteAcceptScreen({ token, onDone }: Props) { } finally { setBusy(false) } - }, [token_, setMe, setServerCapabilities, setActive, companies, user, onDone]) + }, [courseInvite, rawToken, setMe, setServerCapabilities, setActive, companies, user, onDone]) // Auto-accept the moment we have a session AND the preview is `valid`. // Saves a redundant click when the user just signed in to redeem the @@ -183,6 +200,7 @@ export function InviteAcceptScreen({ token, onDone }: Props) { const inv = preview?.invitation const companyName = inv?.company.name ?? 'LingxiLoop' + const course = inv && 'course' in inv ? inv.course : null const inviter = inv?.inviterName ?? 'Someone' const signedIn = !!tokenStr && !!tokenUserId @@ -250,6 +268,14 @@ export function InviteAcceptScreen({ token, onDone }: Props) { /> )} + {!joinedCompany && preview && preview.status === 'archived' && ( + { clearPendingInvite(); onDone() }} + /> + )} + {!joinedCompany && preview && preview.status === 'wrong_email' && inv && (

账号错误

@@ -270,7 +296,13 @@ export function InviteAcceptScreen({ token, onDone }: Props) { { - if (inv) setActive(inv.company.id) + if (inv) { + setActive(inv.company.id) + if ('course' in inv) { + setWorkspaceSession({ companyId: inv.company.id, projectId: inv.course.projectId }) + useApp.getState().selectConversation(inv.course.studyRoomId) + } + } clearPendingInvite() onDone() }} @@ -284,8 +316,9 @@ export function InviteAcceptScreen({ token, onDone }: Props) { {inviter} 邀请您

- {companyName} + {course?.name ?? companyName}

+ {course &&
{companyName} · Study Room
} {inv.note && (
@@ -436,6 +469,7 @@ function SignInToAccept({ token }: { token: string }) { const [busy, setBusy] = useState<'lingxi' | null>(null) const go = (provider: 'lingxi') => { setBusy(provider) + const rawToken = token.startsWith('course:') ? token.slice('course:'.length) : token // Persist BEFORE redirect so the post-OAuth landing can resume here. stashPendingInvite(token) if (isElectron && window.lingxiloop?.auth) { @@ -444,7 +478,7 @@ function SignInToAccept({ token }: { token: string }) { setBusy(null) return } - const inv = encodeURIComponent(token) + const inv = encodeURIComponent(rawToken) // Arm a single-use nonce (anti session-fixation — see AuthScreen). The // nonce rides the return URL's query and must match on the inbound token. const auth = window.lingxiloop.auth @@ -455,11 +489,11 @@ function SignInToAccept({ token }: { token: string }) { if (nonce) done += `?n=${encodeURIComponent(nonce)}` } catch { /* unarmed fallback → token rejected, user retries */ } const ret = encodeURIComponent(done) - void auth.openExternal(`${origin}/api/auth/start/${provider}?return=${ret}&invite=${inv}`) + void auth.openExternal(`${origin}/api/auth/start/${provider}?return=${ret}&invite=${inv}&inviteKind=${token.startsWith('course:') ? 'course' : 'company'}`) })() return } - location.assign(api.authStartUrl(provider, { inviteToken: token })) + location.assign(api.authStartUrl(provider, { inviteToken: rawToken, inviteKind: token.startsWith('course:') ? 'course' : 'company' })) } return (
diff --git a/src/desktop/CompanyCourseManagement.tsx b/src/desktop/CompanyCourseManagement.tsx new file mode 100644 index 00000000..a91011b8 --- /dev/null +++ b/src/desktop/CompanyCourseManagement.tsx @@ -0,0 +1,160 @@ +import { useCallback, useEffect, useState } from 'react' +import { IconArchive, IconBook2, IconCopy, IconPlus, IconTrash, IconUsers } from '@tabler/icons-react' +import { + api, + type ApiCompanyMember, + type ApiCompanyProfile, + type ApiCourse, + type ApiCourseInvitation, + type ApiCourseMember, +} from '@/api/client' +import { setWorkspaceSession } from '@/lib/workspaceSession' +import { InvitePeopleModal } from '@/components/InvitePeopleModal' +import { useApp } from '@/stores/app' +import { useAuth } from '@/stores/auth' +import { useConversations } from '@/stores/conversations' +import { isMockImDevelopment } from '@/lib/devMode' + +type Tab = 'courses' | 'organization' | 'projects' + +const field = 'w-full rounded-xl border border-hairline bg-panel px-3 py-2 text-[13px] text-ink outline-none focus:border-accent' +const button = 'rounded-xl px-3 py-2 text-[12px] font-semibold transition hover:brightness-95 disabled:opacity-50' + +export function CompanyCourseManagement() { + const companyId = useAuth((state) => state.activeCompanyId) + const companyRole = useAuth((state) => state.companies.find((company) => company.id === state.activeCompanyId)?.role ?? 'member') + const isAdmin = companyRole === 'owner' || companyRole === 'admin' + const [tab, setTab] = useState('courses') + const [profile, setProfile] = useState(null) + const [courses, setCourses] = useState([]) + const [projects, setProjects] = useState>>([]) + const [members, setMembers] = useState([]) + const [selectedCourse, setSelectedCourse] = useState(null) + const [courseMembers, setCourseMembers] = useState([]) + const [invitations, setInvitations] = useState([]) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [createdLink, setCreatedLink] = useState(null) + const [companyInviteOpen, setCompanyInviteOpen] = useState(false) + const canCreateCourse = isAdmin || courses.some((course) => course.status === 'active' && course.courseRole === 'teacher') + + const load = useCallback(async () => { + if (!companyId) return + setError(null) + try { + const [courseRows, projectRows, company] = await Promise.all([ + api.listCourses(), api.listProjects(), api.getCompany(companyId), + ]) + setCourses(courseRows); setProjects(projectRows); setProfile(company) + if (isAdmin) setMembers(await api.listCompanyMembers(companyId)) + } catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)) } + }, [companyId, isAdmin]) + + useEffect(() => { void load() }, [load]) + + const openCourse = async (course: ApiCourse) => { + setSelectedCourse(course); setCreatedLink(null); setError(null) + if (!course.canManage) { setCourseMembers([]); setInvitations([]); return } + try { + const [memberRows, invitationRows] = await Promise.all([api.listCourseMembers(course.id), api.listCourseInvitations(course.id)]) + setCourseMembers(memberRows); setInvitations(invitationRows) + } catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)) } + } + + const enterCourse = async (course: ApiCourse) => { + if (!companyId) return + setWorkspaceSession({ companyId, projectId: course.projectId }) + if (isMockImDevelopment()) { + const { activateMockWorkspace } = await import('@/dev/mockIm') + activateMockWorkspace(course.projectId) + } else { + await useConversations.getState().reload() + } + useApp.getState().selectConversation(course.studyRoomId) + useApp.getState().setView('conversations') + } + + const createCourse = async (event: React.FormEvent) => { + event.preventDefault(); setBusy(true); setError(null) + const data = new FormData(event.currentTarget) + try { + const course = await api.createCourse({ name: String(data.get('name') ?? ''), description: String(data.get('description') ?? '') }) + event.currentTarget.reset(); await load(); await openCourse(course) + } catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)) } + finally { setBusy(false) } + } + + const createInvite = async (event: React.FormEvent) => { + event.preventDefault(); if (!selectedCourse) return + setBusy(true); setError(null) + const data = new FormData(event.currentTarget) + try { + const invitation = await api.createCourseInvitation(selectedCourse.id, { + email: String(data.get('email') ?? '').trim() || null, + role: data.get('role') === 'teacher' ? 'teacher' : 'learner', + note: String(data.get('note') ?? '').trim() || null, + expiresInDays: Number(data.get('expiresInDays') ?? 7), + maxUses: Number(data.get('maxUses') ?? 1), + }) + setCreatedLink(invitation.url); event.currentTarget.reset(); await openCourse(selectedCourse) + } catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)) } + finally { setBusy(false) } + } + + return ( +
+
+
+

Company & Courses

管理组织、课程、成员和专属 Study Room

+ +
+ + {error &&
{error}
} + + {tab === 'courses' &&
+
+ {canCreateCourse &&
+

新建课程

+ +