Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/classbot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`를 반환하는지 확인한다.
Expand Down
9 changes: 9 additions & 0 deletions apps/classbot/db/migrations/004_portal_invite_channel.sql
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 4 additions & 1 deletion apps/classbot/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion apps/classbot/docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 서비스 설정

Expand Down
2 changes: 1 addition & 1 deletion apps/classbot/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`는 쿠키의 구성원으로 서버에서 강제한다. 학생은 다른 구성원 개인 일정·개인 시간표·자료를 조회하거나 수정할 수 없고, 관리자 역할만 반 전체 일정을 관리할 수 있다.
14 changes: 13 additions & 1 deletion apps/classbot/server/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 사이여야 합니다.");
Expand Down
45 changes: 44 additions & 1 deletion apps/classbot/server/app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -152,14 +169,19 @@ 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);
const response = await agent.get("/api/portal/overview?from=2026-07-20&to=2026-07-22");
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);
Expand All @@ -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");
Expand Down
23 changes: 13 additions & 10 deletions apps/classbot/server/services/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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") {
Expand All @@ -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));
Expand Down Expand Up @@ -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),
});
}

Expand Down
Loading
Loading