diff --git a/apps/classbot/README.md b/apps/classbot/README.md index 1535abc..b1c231b 100644 --- a/apps/classbot/README.md +++ b/apps/classbot/README.md @@ -44,7 +44,7 @@ npm run release:check ## 운영 배포 -1. Supabase 프로젝트의 SQL 편집기에서 [`db/schema.sql`](./db/schema.sql)을 적용한다. 기존 v2 운영 DB는 [`db/migrations/003_member_timetable.sql`](./db/migrations/003_member_timetable.sql)을 적용한다. +1. Supabase 프로젝트의 SQL 편집기에서 [`db/schema.sql`](./db/schema.sql)을 적용한다. 기존 v2 운영 DB는 [`db/migrations/003_member_timetable.sql`](./db/migrations/003_member_timetable.sql), [`db/migrations/004_portal_invite_channel.sql`](./db/migrations/004_portal_invite_channel.sql) 순서로 적용한다. 2. 기존 Quilo Render 서비스에 Cron·카카오 스킬 비밀값을 설정한다. Supabase 연결과 관리자 로그인은 기존 Quilo 설정을 그대로 쓴다. 3. 기존 Quilo 서비스를 재배포한다. 루트 `postinstall`이 일정 관리 화면을 함께 빌드한다. 4. `GET /schedule/api/health`가 `ok: true`와 `storage: supabase`를 반환하는지 확인한다. diff --git a/apps/classbot/db/migrations/004_portal_invite_channel.sql b/apps/classbot/db/migrations/004_portal_invite_channel.sql new file mode 100644 index 0000000..5987918 --- /dev/null +++ b/apps/classbot/db/migrations/004_portal_invite_channel.sql @@ -0,0 +1,9 @@ +-- Classbot schema v4: one invite may be claimed once by Kakao and once by the +-- student web portal. The two channels retain independent one-time markers. + +alter table public.classbot_invites + add column if not exists portal_used_at timestamptz; + +insert into public.classbot_schema_meta(id, version, applied_at) +values (1, 4, now()) +on conflict (id) do update set version = excluded.version, applied_at = excluded.applied_at; diff --git a/apps/classbot/db/schema.sql b/apps/classbot/db/schema.sql index 73ebf91..bd0dee6 100644 --- a/apps/classbot/db/schema.sql +++ b/apps/classbot/db/schema.sql @@ -7,7 +7,7 @@ create table if not exists public.classbot_schema_meta ( ); insert into public.classbot_schema_meta(id, version, applied_at) -values (1, 3, now()) +values (1, 4, now()) on conflict (id) do update set version = excluded.version, applied_at = excluded.applied_at; create or replace function public.classbot_health_check() @@ -171,9 +171,12 @@ create table if not exists public.classbot_invites ( code_hash text not null unique, expires_at timestamptz not null, used_at timestamptz, + portal_used_at timestamptz, created_at timestamptz not null default now() ); +alter table public.classbot_invites add column if not exists portal_used_at timestamptz; + drop function if exists public.classbot_claim_invite(text, text, text); create or replace function public.classbot_claim_invite( diff --git a/apps/classbot/docs/deployment.md b/apps/classbot/docs/deployment.md index 9b59f6d..12d1831 100644 --- a/apps/classbot/docs/deployment.md +++ b/apps/classbot/docs/deployment.md @@ -31,7 +31,7 @@ select to_regprocedure('public.classbot_replace_member_timetable(uuid,uuid,jsonb)') is not null as member_timetable_rpc; ``` -기대값은 schema version `3`과 모든 RPC의 `true`다. 기존 v2 운영 DB에는 전체 스키마 대신 [`003_member_timetable.sql`](../db/migrations/003_member_timetable.sql)을 적용할 수 있다. 모든 일정 관리 테이블은 RLS가 활성화되고 anon/authenticated 정책은 만들지 않는다. 서버만 service role key로 접근한다. +기대값은 schema version `4`와 모든 RPC의 `true`다. 기존 v2 운영 DB에는 전체 스키마 대신 [`003_member_timetable.sql`](../db/migrations/003_member_timetable.sql), [`004_portal_invite_channel.sql`](../db/migrations/004_portal_invite_channel.sql)을 순서대로 적용할 수 있다. 모든 일정 관리 테이블은 RLS가 활성화되고 anon/authenticated 정책은 만들지 않는다. 서버만 service role key로 접근한다. ## 3. 기존 Render 서비스 설정 diff --git a/apps/classbot/server/README.md b/apps/classbot/server/README.md index ba95730..149809a 100644 --- a/apps/classbot/server/README.md +++ b/apps/classbot/server/README.md @@ -21,4 +21,4 @@ Kakao Event API의 POST `SUCCESS`는 접수 성공일 뿐 실제 발송 성공 구성원 생성, 반 시간표 요일 교체, 개인별 시간표 전체 교체는 Supabase RPC 안에서 대상 행을 잠그고 처리한다. 개인별 시간표 테이블은 RLS를 켜고 anon/authenticated 정책을 만들지 않는다. 관리자 API의 구성원 DTO와 audit JSON에는 `kakao_user_key` 원문을 반환하거나 기록하지 않는다. -학생 포털 최초 로그인은 등록 이름과 일회용 초대 코드를 함께 검증한다. 성공 시 30일 HMAC HttpOnly 쿠키를 발급하며, 학생 요청의 `member_id`는 쿠키의 구성원으로 서버에서 강제한다. 학생은 다른 구성원 일정·자료를 조회하거나 수정할 수 없고, 관리자 역할만 반 전체 일정을 관리할 수 있다. +학생 포털 최초 로그인은 등록 이름과 일회용 초대 코드를 함께 검증한다. 같은 초대 코드는 카카오 이름 등록과 웹 포털에서 채널별로 한 번씩만 사용할 수 있다. 성공 시 30일 HMAC HttpOnly 쿠키를 발급하며, 학생 요청의 `member_id`는 쿠키의 구성원으로 서버에서 강제한다. 학생은 다른 구성원 개인 일정·개인 시간표·자료를 조회하거나 수정할 수 없고, 관리자 역할만 반 전체 일정을 관리할 수 있다. diff --git a/apps/classbot/server/app.js b/apps/classbot/server/app.js index 4bc3ded..8f3c6ed 100644 --- a/apps/classbot/server/app.js +++ b/apps/classbot/server/app.js @@ -484,7 +484,7 @@ export async function createApp(options = {}) { const range = portalRange(req.query || {}); const timetablePromise = (async () => { if (typeof store.listMemberTimetable === "function") { - const memberTimetable = await store.listMemberTimetable(req.portalMember.id); + const memberTimetable = await store.listMemberTimetable(req.portalMember.id, { date: range.from || now() }); if (Array.isArray(memberTimetable) && memberTimetable.length) return memberTimetable; } return store.listTimetable(); @@ -696,6 +696,18 @@ export async function createApp(options = {}) { res.status(201).json({ item: { id: item.id, member_id: item.member_id, expires_at: item.expires_at }, code }); })); + app.get("/api/admin/members/:id/timetable", asyncRoute(async (req, res) => { + const weekday = req.query.weekday === undefined ? undefined : Number(req.query.weekday); + if (weekday !== undefined && (!Number.isInteger(weekday) || weekday < 1 || weekday > 5)) { + throw new HttpError(400, "요일은 1~5 사이여야 합니다."); + } + res.json({ items: await store.listMemberTimetable(req.params.id, { weekday, date: req.query.date || now() }) }); + })); + app.put("/api/admin/members/:id/timetable", asyncRoute(async (req, res) => { + if (!Array.isArray(req.body?.rows)) throw new HttpError(400, "개인 시간표 rows 배열이 필요합니다."); + res.json({ items: await store.replaceMemberTimetable({ memberId: req.params.id, rows: req.body.rows }, "admin") }); + })); + app.get("/api/admin/timetable", asyncRoute(async (req, res) => { const weekday = req.query.weekday === undefined ? undefined : Number(req.query.weekday); if (weekday !== undefined && (!Number.isInteger(weekday) || weekday < 1 || weekday > 5)) throw new HttpError(400, "요일은 1~5 사이여야 합니다."); diff --git a/apps/classbot/server/app.test.js b/apps/classbot/server/app.test.js index 59fcba1..bfaa4b8 100644 --- a/apps/classbot/server/app.test.js +++ b/apps/classbot/server/app.test.js @@ -106,6 +106,23 @@ test("학생 포털은 초대 코드로 최초 로그인하고 HMAC 쿠키 세 assert.deepEqual((await agent.get("/api/portal/session")).body, { authenticated: false }); }); +test("같은 초대 코드는 카카오와 웹 포털에서 채널별로 한 번씩 사용할 수 있다", async () => { + const { app, store } = await fixture(); + const [first, second] = (await store.listMembers()).filter((item) => item.role === "student").slice(0, 2); + + const portalFirstCode = await issuePortalInvite(store, first, "PORT-AL01"); + assert.equal((await request(app).post("/api/portal/login").send({ display_name: first.display_name, invite_code: portalFirstCode })).status, 200); + const kakaoAfterPortal = await store.claimInvite({ code: portalFirstCode, userKey: "kakao-after-portal" }); + assert.equal(kakaoAfterPortal.id, first.id); + await assert.rejects(() => store.claimInvite({ code: portalFirstCode, userKey: "kakao-reuse" }), /올바르지 않거나 만료/); + + const kakaoFirstCode = await issuePortalInvite(store, second, "KAKA-O001"); + const kakaoFirst = await store.claimInvite({ code: kakaoFirstCode, userKey: "kakao-before-portal" }); + assert.equal(kakaoFirst.id, second.id); + assert.equal((await request(app).post("/api/portal/login").send({ display_name: second.display_name, invite_code: kakaoFirstCode })).status, 200); + assert.equal((await request(app).post("/api/portal/login").send({ display_name: second.display_name, invite_code: kakaoFirstCode })).status, 401); +}); + test("학생 포털 로그인 실패는 이름 사칭·중복·비활성 여부를 같은 오류로 숨기고 요청 횟수를 제한한다", async () => { const { app, store } = await fixture(); const members = await store.listMembers(); @@ -152,7 +169,11 @@ test("학생 포털 overview는 반 전체와 본인 일정·게시 공지만 await store.createEvent({ member_id: member.id, category: "assignment", title: "내 개인 일정", due_at: "2026-07-21T10:00:00" }); await store.createEvent({ member_id: other.id, category: "assignment", title: "다른 학생 비밀 일정", due_at: "2026-07-22T10:00:00" }); await store.createNotice({ title: "초안 공지", body: "노출 금지", status: "draft" }); - store.listMemberTimetable = async (memberId) => [{ period: 1, subject: "개인 선택", member_id: memberId }]; + let timetableOptions; + store.listMemberTimetable = async (memberId, options) => { + timetableOptions = options; + return [{ period: 1, subject: "개인 선택", member_id: memberId }]; + }; const agent = request.agent(app); assert.equal((await loginPortal(agent, store, member)).status, 200); @@ -160,6 +181,7 @@ test("학생 포털 overview는 반 전체와 본인 일정·게시 공지만 assert.equal(response.status, 200); assert.deepEqual(response.body.member, { id: member.id, display_name: member.display_name, role: "student" }); assert.deepEqual(response.body.timetable, [{ period: 1, subject: "개인 선택", member_id: member.id }]); + assert.equal(timetableOptions.date, "2026-07-19T15:00:00.000Z"); const titles = response.body.events.map((item) => item.title); assert.equal(titles.includes("반 전체 행사"), true); assert.equal(titles.includes("내 개인 일정"), true); @@ -170,6 +192,27 @@ test("학생 포털 overview는 반 전체와 본인 일정·게시 공지만 assert.equal((await agent.get("/api/portal/overview?from=not-a-date")).status, 400); }); +test("관리자 API는 구성원 개인 시간표를 조회하고 원자적으로 교체한다", async () => { + const { app, agent, store } = await fixture(); + const member = (await store.listMembers()).find((item) => item.role === "student"); + await login(agent); + const saved = await agent.put(`/api/admin/members/${member.id}/timetable`).send({ rows: [{ + weekday: 1, + period: 1, + subject: "개인 선택 과목", + teacher: "담당 교사", + room: "선택 강의실", + effective_from: "2026-08-01", + }] }); + assert.equal(saved.status, 200); + assert.equal(saved.body.items[0].member_id, member.id); + const listed = await agent.get(`/api/admin/members/${member.id}/timetable?weekday=1&date=2026-08-01`); + assert.equal(listed.status, 200); + assert.equal(listed.body.items[0].subject, "개인 선택 과목"); + assert.equal((await request(app).put(`/api/admin/members/${member.id}/timetable`).send({ rows: [] })).status, 401); + assert.equal((await agent.put(`/api/admin/members/${member.id}/timetable`).send({ rows: "invalid" })).status, 400); +}); + test("학생 포털 파일 목록은 반 전체와 본인 자료만 15분 열기·다운로드 URL로 제공한다", async () => { const { app, store } = await fixture(); const students = (await store.listMembers()).filter((item) => item.role === "student"); diff --git a/apps/classbot/server/services/commands.js b/apps/classbot/server/services/commands.js index b455b0f..45a75ee 100644 --- a/apps/classbot/server/services/commands.js +++ b/apps/classbot/server/services/commands.js @@ -268,27 +268,28 @@ async function answerFileQuery({ command, requester, store, makeFileUrl, quickRe } } -async function answerReadQuery({ command, intent, member, store, now, quickReplies }) { +async function answerReadQuery({ command, intent, member, store, now, quickReplies, privateAccess = false }) { const period = readPeriod(command, intent); const replies = quickReplies || personalizedQuickReplies(member.display_name); + const targetMemberId = privateAccess ? member.id : undefined; if (intent === "timetable") { if (period.kind === "full-timetable") { - const bundle = await getWeekTimetable(store, now, { targetMemberId: member.id }); + const bundle = await getWeekTimetable(store, now, { targetMemberId }); return simpleTextResponse( `${member.display_name}님의 월~금 전체 시간표\n\n${formatWeekTimetable(bundle)}`, replies, ); } if (period.kind === "week") { - const bundle = await getWeekTimetable(store, now, { weekOffset: period.offset, targetMemberId: member.id }); + const bundle = await getWeekTimetable(store, now, { weekOffset: period.offset, targetMemberId }); return simpleTextResponse( `${member.display_name}님의 ${period.label} 시간표\n\n${formatWeekTimetable(bundle)}`, replies, ); } const target = dateForSeoulOffset(now, period.offset); - const bundle = await getDaySchedule(store, target, { targetMemberId: member.id }); + const bundle = await getDaySchedule(store, target, { targetMemberId }); return simpleTextResponse( `${member.display_name}님의 ${formatKoreanDate(target)} 시간표\n\n${formatTimetableRows(bundle.timetable)}`, replies, @@ -299,13 +300,13 @@ async function answerReadQuery({ command, intent, member, store, now, quickRepli let rangeLabel; if (period.kind === "day") { const target = dateForSeoulOffset(now, period.offset); - events = (await getDaySchedule(store, target, { targetMemberId: member.id })).events; + events = (await getDaySchedule(store, target, { targetMemberId })).events; rangeLabel = formatKoreanDate(target); } else if (period.kind === "week") { - events = await getWeekEvents(store, now, { weekOffset: period.offset, targetMemberId: member.id }); + events = await getWeekEvents(store, now, { weekOffset: period.offset, targetMemberId }); rangeLabel = period.label; } else if (period.kind === "remaining-week") { - events = (await getWeekEvents(store, now, { targetMemberId: member.id })) + events = (await getWeekEvents(store, now, { targetMemberId })) .filter((event) => new Date(event.due_at).getTime() >= now.getTime()); rangeLabel = period.label; } else if (period.kind === "month") { @@ -314,22 +315,23 @@ async function answerReadQuery({ command, intent, member, store, now, quickRepli from: bounds.start.toISOString(), to: bounds.end.toISOString(), status: "scheduled", - targetMemberId: member.id, + targetMemberId, }); rangeLabel = bounds.label; } else if (period.kind === "next") { events = await store.listEvents({ from: now.toISOString(), status: "scheduled", - targetMemberId: member.id, + targetMemberId, }); events = events.slice(0, 1); rangeLabel = period.label; } else { - events = await getUpcomingEvents(store, now, period.days, { targetMemberId: member.id }); + events = await getUpcomingEvents(store, now, period.days, { targetMemberId }); rangeLabel = `앞으로 ${period.days}일`; } + if (!privateAccess) events = events.filter((event) => event.member_id == null); const kind = eventKind(intent); if (kind.category) events = events.filter((event) => event.category === kind.category); if (kind.categories) events = events.filter((event) => kind.categories.has(event.category)); @@ -407,6 +409,7 @@ export async function handleKakaoCommand({ payload, store, now = new Date(), mak store, now, quickReplies: targetQuickReplies, + privateAccess: Boolean(requester && requester.id === target.member.id), }); } diff --git a/apps/classbot/server/services/commands.test.js b/apps/classbot/server/services/commands.test.js index a1fe4b2..22b1b44 100644 --- a/apps/classbot/server/services/commands.test.js +++ b/apps/classbot/server/services/commands.test.js @@ -27,7 +27,7 @@ function text(response) { return response.template.outputs[0].simpleText.text; } -test("익명 그룹챗 개인 조회는 반 전체와 대상 개인 일정만 반환하고 개인화 Quick Reply를 붙인다", async () => { +test("익명 그룹챗 이름 조회는 반 전체 일정만 반환하고 개인 일정은 숨긴다", async () => { const store = fixture(); await store.createEvent({ title: "반 전체 준비", due_at: "2026-07-15T16:00:00" }); await store.createEvent({ member_id: store.members[0].id, title: "홍길동 개인 준비", due_at: "2026-07-15T17:00:00" }); @@ -36,8 +36,7 @@ test("익명 그룹챗 개인 조회는 반 전체와 대상 개인 일정만 const response = await ask(store, "오늘 일정 홍길동"); assert.match(text(response), /홍길동님의 7월 15일 수요일 일정/); assert.match(text(response), /반 전체 준비/); - assert.match(text(response), /홍길동 개인 준비/); - assert.doesNotMatch(text(response), /김학생 개인 준비/); + assert.doesNotMatch(text(response), /홍길동 개인 준비|김학생 개인 준비/); const replies = response.template.quickReplies; assert.deepEqual(replies.map((item) => item.messageText), [ "오늘 일정 홍길동", @@ -141,26 +140,26 @@ test("파일 형식·별칭이 다르거나 URL 발급기가 없으면 안전한 }); test("다음 일정은 현재 이후 가장 빠른 반 전체 또는 개인 일정 한 건만 보여준다", async () => { - const store = fixture(); + const store = fileFixture(); await store.createEvent({ title: "이미 지난 반 일정", due_at: "2026-07-15T10:00:00+09:00" }); await store.createEvent({ member_id: store.members[1].id, title: "다른 학생 다음 일정", due_at: "2026-07-15T13:00:00+09:00" }); await store.createEvent({ member_id: store.members[0].id, title: "가장 가까운 개인 일정", due_at: "2026-07-15T13:30:00+09:00" }); await store.createEvent({ title: "그다음 반 일정", due_at: "2026-07-15T14:00:00+09:00" }); - const response = await ask(store, "다음 일정 홍길동"); + const response = await ask(store, "다음 일정", { userId: "joined-hong" }); assert.match(text(response), /홍길동님의 다음 일정/); assert.match(text(response), /가장 가까운 개인 일정/); assert.doesNotMatch(text(response), /이미 지난 반 일정|다른 학생 다음 일정|그다음 반 일정/); }); test("이번 달 일정은 서울 달력 경계 안의 반 전체와 본인 일정만 보여준다", async () => { - const store = fixture(); + const store = fileFixture(); await store.createEvent({ title: "7월 반 일정", due_at: "2026-07-01T00:00:00+09:00" }); await store.createEvent({ member_id: store.members[0].id, title: "7월 개인 일정", due_at: "2026-07-31T23:59:00+09:00" }); await store.createEvent({ member_id: store.members[1].id, title: "7월 타인 일정", due_at: "2026-07-20T12:00:00+09:00" }); await store.createEvent({ title: "8월 일정", due_at: "2026-08-01T00:00:00+09:00" }); - const response = await ask(store, "이번 달 일정 홍길동"); + const response = await ask(store, "이번 달 일정", { userId: "joined-hong" }); assert.match(text(response), /홍길동님의 2026년 7월 일정/); assert.match(text(response), /7월 반 일정/); assert.match(text(response), /7월 개인 일정/); @@ -168,12 +167,12 @@ test("이번 달 일정은 서울 달력 경계 안의 반 전체와 본인 일 }); test("수행평가·과제 통합 요약은 두 유형만 함께 보여준다", async () => { - const store = fixture(); + const store = fileFixture(); await store.createEvent({ category: "assessment", title: "물리 수행평가", due_at: "2026-07-16T13:00:00+09:00" }); await store.createEvent({ member_id: store.members[0].id, category: "assignment", title: "영어 과제", due_at: "2026-07-17T13:00:00+09:00" }); await store.createEvent({ category: "class", title: "진로 수업", due_at: "2026-07-18T13:00:00+09:00" }); - const response = await ask(store, "수행평가 과제 통합 요약 홍길동"); + const response = await ask(store, "수행평가 과제 통합 요약", { userId: "joined-hong" }); assert.match(text(response), /수행평가·과제 통합 요약/); assert.match(text(response), /물리 수행평가/); assert.match(text(response), /영어 과제/); @@ -181,7 +180,7 @@ test("수행평가·과제 통합 요약은 두 유형만 함께 보여준다", }); test("시간표 전체는 대상 학생의 개인 시간표를 우선해서 보여준다", async () => { - const store = fixture(); + const store = fileFixture(); await store.replaceMemberTimetable({ memberId: store.members[0].id, rows: [{ weekday: 1, period: 1, @@ -190,7 +189,7 @@ test("시간표 전체는 대상 학생의 개인 시간표를 우선해서 보 room: "선택 강의실", effective_from: "2026-07-01", }] }); - const response = await ask(store, "시간표 전체 홍길동"); + const response = await ask(store, "시간표 전체", { userId: "joined-hong" }); assert.match(text(response), /홍길동님의 월~금 전체 시간표/); assert.match(text(response), /7월 13일 월요일/); assert.match(text(response), /7월 17일 금요일/); @@ -198,13 +197,13 @@ test("시간표 전체는 대상 학생의 개인 시간표를 우선해서 보 }); test("이번 주 남은 일정은 현재 시각 이후 일정만 유지한다", async () => { - const store = fixture(); + const store = fileFixture(); await store.createEvent({ title: "오늘 지난 일정", due_at: "2026-07-15T10:00:00+09:00" }); await store.createEvent({ title: "오늘 남은 일정", due_at: "2026-07-15T13:00:00+09:00" }); await store.createEvent({ member_id: store.members[0].id, title: "금요일 개인 일정", due_at: "2026-07-17T13:00:00+09:00" }); await store.createEvent({ title: "다음 주 일정", due_at: "2026-07-20T13:00:00+09:00" }); - const response = await ask(store, "이번 주 남은 일정 홍길동"); + const response = await ask(store, "이번 주 남은 일정", { userId: "joined-hong" }); assert.match(text(response), /홍길동님의 이번 주 남은 일정/); assert.match(text(response), /오늘 남은 일정/); assert.match(text(response), /금요일 개인 일정/); @@ -255,7 +254,7 @@ test("이름등록은 기존 가입과 같은 초대코드 매핑을 사용하 assert.equal(response.template.quickReplies.every((item) => !item.messageText.includes("홍길동")), true); }); -test("active 요청자는 모든 본인 조회 명령을 이름 없이 사용하고 명시한 다른 이름 일정도 조회한다", async () => { +test("active 요청자는 본인 개인 조회를 이름 없이 쓰고 다른 이름 조회에서는 반 일정만 본다", async () => { const store = fileFixture(); await store.createEvent({ title: "오늘 반 일정", due_at: "2026-07-15T16:00:00" }); await store.createEvent({ member_id: store.members[0].id, category: "assessment", title: "홍길동 수행평가", due_at: "2026-07-16T16:00:00" }); @@ -285,7 +284,8 @@ test("active 요청자는 모든 본인 조회 명령을 이름 없이 사용하 const other = await ask(store, "오늘 일정 김학생", { userId: "joined-hong" }); assert.match(text(other), /김학생님의/); - assert.match(text(other), /김학생 개인 일정/); + assert.match(text(other), /오늘 반 일정/); + assert.doesNotMatch(text(other), /김학생 개인 일정/); assert.equal(other.template.quickReplies[0].messageText, "오늘 일정 김학생"); }); @@ -305,18 +305,18 @@ test("등록된 요청자의 알림·공지 응답 Quick Reply에도 이름 suff }); test("오늘·내일·모레·이번 주·다음 주와 시험·숙제 변형을 대상 범위로 해석한다", async () => { - const store = fixture(); + const store = fileFixture(); await store.createEvent({ member_id: store.members[0].id, category: "assessment", title: "오늘 시험", due_at: "2026-07-15T16:00:00" }); await store.createEvent({ member_id: store.members[0].id, category: "assignment", title: "내일 숙제", due_at: "2026-07-16T16:00:00" }); await store.createEvent({ member_id: store.members[0].id, category: "class", title: "모레 상담", due_at: "2026-07-17T16:00:00" }); await store.createEvent({ member_id: store.members[0].id, category: "assessment", title: "다음 주 시험", due_at: "2026-07-21T16:00:00" }); - assert.match(text(await ask(store, "오늘 시험 홍길동")), /오늘 시험/); - assert.match(text(await ask(store, "내일 숙제 홍길동")), /내일 숙제/); - assert.match(text(await ask(store, "모레 뭐 있어 홍길동?")), /모레 상담/); - assert.match(text(await ask(store, "이번주 일정 홍길동")), /오늘 시험/); - assert.match(text(await ask(store, "다음 주 시험 홍길동")), /다음 주 시험/); - assert.match(text(await ask(store, "과제 홍길동")), /내일 숙제/); + assert.match(text(await ask(store, "오늘 시험 홍길동", { userId: "joined-hong" })), /오늘 시험/); + assert.match(text(await ask(store, "내일 숙제 홍길동", { userId: "joined-hong" })), /내일 숙제/); + assert.match(text(await ask(store, "모레 뭐 있어 홍길동?", { userId: "joined-hong" })), /모레 상담/); + assert.match(text(await ask(store, "이번주 일정 홍길동", { userId: "joined-hong" })), /오늘 시험/); + assert.match(text(await ask(store, "다음 주 시험 홍길동", { userId: "joined-hong" })), /다음 주 시험/); + assert.match(text(await ask(store, "과제 홍길동", { userId: "joined-hong" })), /내일 숙제/); }); test("시간표도 이름을 맨 뒤에 요구하며 요청자 가입 없이 일간·주간 조회한다", async () => { diff --git a/apps/classbot/server/store/memory-store.js b/apps/classbot/server/store/memory-store.js index 2526bc0..1edf422 100644 --- a/apps/classbot/server/store/memory-store.js +++ b/apps/classbot/server/store/memory-store.js @@ -361,7 +361,10 @@ export class MemoryStore { if (member.status === "active") throw new Error("이미 가입한 구성원에게 초대 코드를 만들 수 없습니다."); const now = nowIso(); for (const previous of this.invites) { - if (previous.member_id === memberId && !previous.used_at) previous.used_at = now; + if (previous.member_id === memberId && (!previous.used_at || !previous.portal_used_at)) { + previous.used_at = previous.used_at || now; + previous.portal_used_at = previous.portal_used_at || now; + } } const invite = { id: id("invite"), @@ -370,6 +373,7 @@ export class MemoryStore { code_hash: codeHash, expires_at: expiresAt, used_at: null, + portal_used_at: null, created_at: nowIso(), }; this.invites.push(invite); @@ -401,17 +405,17 @@ export class MemoryStore { const invite = this.invites.find((item) => ( item.member_id === memberId && item.code_hash === codeHash - && !item.used_at + && !item.portal_used_at && new Date(item.expires_at).getTime() > Date.now() )); if (!member || !invite) throw new Error("이름 또는 초대 코드를 확인할 수 없습니다."); - invite.used_at = nowIso(); + invite.portal_used_at = nowIso(); await this.appendAudit({ actor: member.id, action: "portal.login", entityType: "invite", entityId: invite.id, - after: { member_id: member.id, used_at: invite.used_at }, + after: { member_id: member.id, portal_used_at: invite.portal_used_at }, }); return clone(member); } diff --git a/apps/classbot/server/store/schema-security.test.js b/apps/classbot/server/store/schema-security.test.js index 636eca0..e0b53b6 100644 --- a/apps/classbot/server/store/schema-security.test.js +++ b/apps/classbot/server/store/schema-security.test.js @@ -34,7 +34,7 @@ test("스키마 버전 health RPC와 전체 RLS가 운영 준비 상태를 검 assert.match(schema, /create table if not exists public\.classbot_schema_meta/); assert.match(schema, /create or replace function public\.classbot_health_check\(\)/); assert.match(schema, /grant execute on function public\.classbot_health_check\(\) to service_role/); - assert.match(schema, /values \(1, 3, now\(\)\)/); + assert.match(schema, /values \(1, 4, now\(\)\)/); for (const table of ["schema_meta", "classes", "members", "invites", "timetable", "member_timetable", "events", "notices", "files", "notifications", "audit_logs"]) { assert.match(schema, new RegExp(`alter table public\\.classbot_${table} enable row level security`)); } @@ -51,7 +51,13 @@ test("개인별 시간표는 학급-구성원 복합 경계와 원자적 전체 assert.match(replaceFunction, /class_id = p_class_id[\s\S]*id = p_member_id[\s\S]*for update/); assert.match(replaceFunction, /delete from public\.classbot_member_timetable/); assert.match(storeSource, /\.rpc\("classbot_replace_member_timetable"/); - assert.match(storeSource, /Number\(version\) !== 3/); + assert.match(storeSource, /Number\(version\) !== 4/); +}); + +test("카카오와 학생 포털은 같은 초대 코드의 일회성 사용 상태를 채널별로 분리한다", () => { + assert.match(schema, /portal_used_at timestamptz/); + assert.match(storeSource, /update\(\{ portal_used_at: usedAt \}\)/); + assert.match(storeSource, /\.is\("portal_used_at", null\)/); }); test("자료실은 비공개 서버 경계와 대상별 별칭 중복 방지를 스키마에 둔다", () => { diff --git a/apps/classbot/server/store/supabase-store.js b/apps/classbot/server/store/supabase-store.js index 5cebb3b..285fd39 100644 --- a/apps/classbot/server/store/supabase-store.js +++ b/apps/classbot/server/store/supabase-store.js @@ -135,7 +135,7 @@ export class SupabaseStore { async healthCheck() { await this.ensureClassroom(); const version = unwrap(await this.client.rpc("classbot_health_check"), "학급 저장소 상태 확인 실패"); - if (Number(version) !== 3) throw new Error("지원하지 않는 Classbot 데이터베이스 스키마입니다."); + if (Number(version) !== 4) throw new Error("지원하지 않는 Classbot 데이터베이스 스키마입니다."); return { ok: true, storage: "supabase" }; } @@ -240,10 +240,10 @@ export class SupabaseStore { unwrap( await this.client .from("classbot_invites") - .update({ used_at: new Date().toISOString() }) + .update({ used_at: new Date().toISOString(), portal_used_at: new Date().toISOString() }) .eq("class_id", classroom.id) .eq("member_id", memberId) - .is("used_at", null), + .or("used_at.is.null,portal_used_at.is.null"), "기존 초대 코드 정리 실패", ); const invite = unwrap( @@ -282,13 +282,13 @@ export class SupabaseStore { const invite = unwrap( await this.client .from("classbot_invites") - .update({ used_at: usedAt }) + .update({ portal_used_at: usedAt }) .eq("class_id", classroom.id) .eq("member_id", memberId) .eq("code_hash", hashInviteCode(code)) - .is("used_at", null) + .is("portal_used_at", null) .gt("expires_at", usedAt) - .select("id,member_id,used_at") + .select("id,member_id,portal_used_at") .maybeSingle(), "학생 포털 초대 코드 확인 실패", ); @@ -300,7 +300,7 @@ export class SupabaseStore { action: "portal.login", entityType: "invite", entityId: invite.id, - after: { member_id: member.id, used_at: invite.used_at }, + after: { member_id: member.id, portal_used_at: invite.portal_used_at }, }).catch(() => {}); return member; } diff --git a/apps/classbot/src/api/client.js b/apps/classbot/src/api/client.js index dc3baf9..f93ad03 100644 --- a/apps/classbot/src/api/client.js +++ b/apps/classbot/src/api/client.js @@ -113,6 +113,13 @@ export const api = { createMember(input, options) { return remoteOrLocal(() => request("/api/admin/members", { method: "POST", headers: idempotencyHeaders("member", input, options), body: input }), () => createItem("members", "member", { status: "invited", notification_enabled: true, daily_digest_enabled: true, ...input })); }, updateMember(id, patch) { return remoteOrLocal(() => request(`/api/admin/members/${id}`, { method: "PATCH", body: patch }), () => updateItem("members", id, patch)); }, inviteMember(id, options = {}) { return remoteOrLocal(() => request(`/api/admin/members/${id}/invite`, { method: "POST", headers: { "Idempotency-Key": options.idempotencyKey || createIdempotencyKey("invite") } }), () => ({ invite_url: `${location.origin}/join/demo-${id.slice(-4)}`, expires_at: new Date(Date.now() + 7 * 86_400_000).toISOString() })); }, + memberTimetable(id, { weekday, date } = {}) { + const query = new URLSearchParams(); + if (weekday != null) query.set("weekday", String(weekday)); + if (date) query.set("date", date); + return request(`/api/admin/members/${id}/timetable${query.size ? `?${query.toString()}` : ""}`); + }, + saveMemberTimetable(id, rows) { return request(`/api/admin/members/${id}/timetable`, { method: "PUT", body: { rows } }); }, timetable() { return remoteOrLocal(() => request("/api/admin/timetable"), () => ({ items: structuredClone(localState.timetable) })); }, saveTimetable(weekday, items) { return remoteOrLocal(async () => { diff --git a/apps/classbot/src/portal/PortalCalendar.jsx b/apps/classbot/src/portal/PortalCalendar.jsx index b264c82..e11c0be 100644 --- a/apps/classbot/src/portal/PortalCalendar.jsx +++ b/apps/classbot/src/portal/PortalCalendar.jsx @@ -92,7 +92,7 @@ export default function PortalCalendar({ onOverview }) { setLoading(true); setError(""); api.portalOverview(from, to).then((result) => { if (cancelled) return; - const next = { member: result.member || null, classroom: result.classroom || null, timetable: result.timetable || [], events: result.events || [], notices: result.notices || [] }; + const next = { member: result.member || null, classroom: result.classroom || null, timetable: result.timetable || [], events: (result.events || []).filter((event) => event.status !== "cancelled"), notices: result.notices || [] }; setData(next); onOverview?.(next); }).catch((err) => { if (!cancelled) setError(err.message || "일정을 불러오지 못했습니다."); }).finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; };