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
9 changes: 9 additions & 0 deletions apps/classbot/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,12 @@ KAKAO_EVENT_API_BASE=https://bot-api.kakao.com
CLASSBOT_CLASS_CODE=2-4
CLASSBOT_CLASS_NAME=2학년 4반
CLASSBOT_TIMEZONE=Asia/Seoul

# Google Drive 자료실. 기본값은 이름이 정확히 `구민준`인 Quilo 계정이다.
# 폴더는 해당 Quilo OAuth 연결로 `Quilo schedule 자료실`을 자동 생성한다.
CLASSBOT_GOOGLE_DRIVE_OWNER_NAME=구민준
# UUID override는 동명이인 등 이름 조회를 쓸 수 없을 때만 지정한다.
# CLASSBOT_GOOGLE_DRIVE_OWNER_USER_ID=
CLASSBOT_GOOGLE_DRIVE_FOLDER_NAME=Quilo schedule 자료실
# drive.file scope로 같은 Quilo 앱이 만든 폴더만 override할 수 있다.
# CLASSBOT_GOOGLE_DRIVE_FOLDER_ID=
19 changes: 10 additions & 9 deletions apps/classbot/README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
# Quilo schedule

2학년 4반의 개인별 시간표, 수행평가·과제 일정, 반 공지와 카카오톡 조회를 관리하는 Quilo 내장 기능이다. 관리자와 학생 화면은 데스크톱·모바일을 지원한다. 학생은 최초 한 번 이름과 초대 코드로 본인 확인한 뒤 웹과 카카오에서 이름을 반복 입력하지 않아도 된다.
2학년 4반의 개인별 시간표, 수행평가·과제 일정, 반 공지와 카카오톡 조회를 관리하는 Quilo 내장 기능이다. 관리자와 학생 화면은 데스크톱·모바일을 지원한다. 웹은 기존 Quilo 계정 이름이 16명 명단과 정확히 일치할 때 열리고, 카카오는 최초 한 번 명단 이름을 등록한 뒤 이름을 반복 입력하지 않아도 된다.

## 현재 구현 범위

- 관리자용 오늘 대시보드, 일정, 시간표, 반 공지, 자료실, 구성원, 알림 기록, 설정
- 관리자·학생 공통 메뉴: 오늘, 일정, 공지, 자료실, 구성원, 설정
- 학생용 월·주·일 캘린더, 개인별 시간표, 일정 추가, PDF·이미지 드라이브
- 최대 16명 구성원과 1회용 초대 코드 가입, 30일 HttpOnly 학생 포털 세션
- 카카오 챗봇 명령: `오늘 일정`, `다음 일정`, `이번 달 일정`, `이번 주 남은 일정`, `수행평가 과제 통합 요약`, `시간표 전체`, `자료 목록`, `PDF 별칭`, `이미지 별칭`
- 미등록 사용자는 질문 맨 뒤에 등록 이름을 붙일 수 있고, `이름등록 ABCD-EFGH`를 한 뒤에는 본인 조회에서 이름을 생략한다.
- 최대 16명 구성원과 기존 Quilo 로그인 세션 기반 접근 제어
- 카카오 챗봇 명령: `도움말`, `이름 등록 구민준`, `오늘 일정`, `다음 일정`, `이번 달 일정`, `이번 주 남은 일정`, `수행평가 과제 통합 요약`, `시간표 전체`, `파일 리스트`, `김종수T 학습지`
- 파일명·별칭이 정확히 일치하면 바로 보내고, 비슷하면 최대 3개 후보를 물은 뒤 `맞아` 또는 번호 응답으로 보낸다.
- 반 전체 일정과 구성원별 개인 일정, 개인 일정 대상자에게만 보내는 알림
- 학생은 자기 개인 일정만 생성·수정·삭제하고, 관리자 역할만 반 전체 일정을 생성·수정·삭제한다.
- 반 전체 자료와 본인 개인 자료만 노출하는 비공개 파일 저장소와 15분 열기·다운로드 링크
- 반 전체 자료는 Google Drive, 개인 자료는 비공개 Supabase 저장소에 보관하고 15분 프록시 링크로만 연다.
- Google Drive 운영 계정은 기존 Quilo의 `구민준` 계정을 정확한 이름으로 찾아 연결한다.
- 일정 마감·변경, 평일 아침 시간표, 반 공지 Event API 알림
- Event API 작업 결과 조회와 사용자별 성공·실패 기록
- 일정·공지 생성 멱등성, 명시적 실패 재시도, 감사 로그
Expand Down Expand Up @@ -44,7 +45,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), [`db/migrations/004_portal_invite_channel.sql`](./db/migrations/004_portal_invite_channel.sql) 순서로 적용한다.
1. Supabase 프로젝트의 SQL 편집기에서 [`db/schema.sql`](./db/schema.sql)을 적용한다. 기존 v4 운영 DB는 [`db/migrations/005_kakao_name_registration.sql`](./db/migrations/005_kakao_name_registration.sql) 적용한다.
2. 기존 Quilo Render 서비스에 Cron·카카오 스킬 비밀값을 설정한다. Supabase 연결과 관리자 로그인은 기존 Quilo 설정을 그대로 쓴다.
3. 기존 Quilo 서비스를 재배포한다. 루트 `postinstall`이 일정 관리 화면을 함께 빌드한다.
4. `GET /schedule/api/health`가 `ok: true`와 `storage: supabase`를 반환하는지 확인한다.
Expand All @@ -59,8 +60,8 @@ npm run release:check
- 스킬 URL: `POST https://quilolab.com/schedule/api/kakao/skill?secret=CLASSBOT_KAKAO_SKILL_SECRET`
- Event 이름: 관리자센터 값과 `KAKAO_EVENT_NAME`을 정확히 동일하게 설정
- Event API 활성화: 준비가 끝난 뒤 `KAKAO_EVENT_ENABLED=true`
- 최초 이름 등록: 관리자 화면에서 받은 일회용 코드를 챗봇에 `이름등록 ABCD-EFGH`로 입력
- 등록 후 조회: `오늘 일정`, `내일 시간표`, `시간표 전체`, `자료 목록`처럼 이름 없이 사용
- 최초 이름 등록: 챗봇에 명단 이름 그대로 `이름 등록 구민준` 입력. 초대 코드는 필요 없다.
- 등록 후 조회: `오늘 일정`, `내일 시간표`, `시간표 전체`, `파일 리스트`처럼 이름 없이 사용
- 명시적 대상 조회: 미등록 상태이거나 다른 구성원의 공개 일정을 볼 때만 `오늘 일정 등록이름`처럼 이름을 맨 뒤에 입력

Event API의 POST 성공은 접수 성공일 뿐 실제 전송 완료가 아니다. 이 서비스는 `taskId` 결과 조회 후에만 `sent`로 기록하며, 실패는 자동 재시도하지 않는다. 상세 운영 정책은 [`server/README.md`](./server/README.md), 공식 연동 근거와 열품타 정책은 [`docs/integrations.md`](./docs/integrations.md)를 참고한다.
Expand Down
123 changes: 123 additions & 0 deletions apps/classbot/db/migrations/005_kakao_name_registration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
-- Classbot schema v5: exact roster-name Kakao registration and short-lived
-- pending file candidates used by the conversational file confirmation flow.

create or replace function public.classbot_claim_member_by_name(
p_class_id uuid,
p_display_name text,
p_user_key text,
p_user_key_type text default 'botUserKey'
)
returns setof public.classbot_members
language plpgsql
security definer
set search_path = public
as $$
declare
selected_class public.classbot_classes%rowtype;
selected_member public.classbot_members%rowtype;
matching_count integer;
begin
if coalesce(trim(p_display_name), '') = '' then
raise exception '명단의 이름을 정확히 입력해 주세요.';
end if;
if char_length(trim(p_display_name)) > 40 then
raise exception '명단의 이름을 40자 이내로 정확히 입력해 주세요.';
end if;
if coalesce(trim(p_user_key), '') = '' then
raise exception '카카오 사용자 식별값이 필요합니다.';
end if;
if p_user_key_type not in ('botUserKey', 'plusfriendUserKey', 'appUserId') then
raise exception '올바른 카카오 사용자 식별값 유형이 아닙니다.';
end if;

select * into selected_class
from public.classbot_classes
where id = p_class_id
for update;
if not found then
raise exception '학급을 찾을 수 없습니다.';
end if;

select count(*) into matching_count
from public.classbot_members
where class_id = p_class_id
and status <> 'left'
and display_name = trim(p_display_name);
if matching_count = 0 then
raise exception '명단에서 이름을 찾을 수 없습니다. 이름을 정확히 입력해 주세요.';
end if;
if matching_count > 1 then
raise exception '명단에 동명이인이 있어 이름만으로 등록할 수 없습니다.';
end if;

select * into selected_member
from public.classbot_members
where class_id = p_class_id
and status <> 'left'
and display_name = trim(p_display_name)
for update;
if selected_member.status = 'disabled' then
raise exception '비활성 구성원은 이름을 등록할 수 없습니다.';
end if;
if exists (
select 1 from public.classbot_members
where kakao_user_key = trim(p_user_key)
and id <> selected_member.id
) then
raise exception '이미 다른 구성원으로 등록된 카카오 계정입니다.';
end if;
if selected_member.kakao_user_key is not null
and selected_member.kakao_user_key <> trim(p_user_key) then
raise exception '이미 다른 카카오 계정에 등록된 이름입니다. 관리자에게 문의해 주세요.';
end if;
if selected_member.kakao_user_key = trim(p_user_key)
and selected_member.status = 'active' then
return next selected_member;
return;
end if;

update public.classbot_members
set kakao_user_key = trim(p_user_key),
kakao_user_key_type = p_user_key_type,
status = 'active',
joined_at = coalesce(joined_at, now()),
updated_at = now()
where class_id = p_class_id and id = selected_member.id
returning * into selected_member;
return next selected_member;
end;
$$;

revoke all on function public.classbot_claim_member_by_name(uuid, text, text, text) from public;
do $$
begin
if exists (select 1 from pg_roles where rolname = 'service_role') then
execute 'grant execute on function public.classbot_claim_member_by_name(uuid, text, text, text) to service_role';
end if;
end;
$$;

create table if not exists public.classbot_kakao_states (
member_id uuid primary key references public.classbot_members(id) on delete cascade,
class_id uuid not null references public.classbot_classes(id) on delete cascade,
pending_file_ids text[] not null check (cardinality(pending_file_ids) between 1 and 3),
pending_expires_at timestamptz not null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
foreign key (class_id, member_id)
references public.classbot_members(class_id, id) on delete cascade
);

create index if not exists classbot_kakao_states_expiry_idx
on public.classbot_kakao_states(class_id, pending_expires_at);

drop trigger if exists set_updated_at on public.classbot_kakao_states;
create trigger set_updated_at
before update on public.classbot_kakao_states
for each row execute function public.classbot_set_updated_at();

alter table public.classbot_kakao_states enable row level security;

insert into public.classbot_schema_meta(id, version, applied_at)
values (1, 5, now())
on conflict (id) do update set version = excluded.version, applied_at = excluded.applied_at;
114 changes: 113 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, 4, now())
values (1, 5, 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 @@ -245,6 +245,102 @@ begin
end;
$$;

create or replace function public.classbot_claim_member_by_name(
p_class_id uuid,
p_display_name text,
p_user_key text,
p_user_key_type text default 'botUserKey'
)
returns setof public.classbot_members
language plpgsql
security definer
set search_path = public
as $$
declare
selected_class public.classbot_classes%rowtype;
selected_member public.classbot_members%rowtype;
matching_count integer;
begin
if coalesce(trim(p_display_name), '') = '' then
raise exception '명단의 이름을 정확히 입력해 주세요.';
end if;
if char_length(trim(p_display_name)) > 40 then
raise exception '명단의 이름을 40자 이내로 정확히 입력해 주세요.';
end if;
if coalesce(trim(p_user_key), '') = '' then
raise exception '카카오 사용자 식별값이 필요합니다.';
end if;
if p_user_key_type not in ('botUserKey', 'plusfriendUserKey', 'appUserId') then
raise exception '올바른 카카오 사용자 식별값 유형이 아닙니다.';
end if;

select * into selected_class
from public.classbot_classes
where id = p_class_id
for update;
if not found then
raise exception '학급을 찾을 수 없습니다.';
end if;

select count(*) into matching_count
from public.classbot_members
where class_id = p_class_id
and status <> 'left'
and display_name = trim(p_display_name);
if matching_count = 0 then
raise exception '명단에서 이름을 찾을 수 없습니다. 이름을 정확히 입력해 주세요.';
end if;
if matching_count > 1 then
raise exception '명단에 동명이인이 있어 이름만으로 등록할 수 없습니다.';
end if;

select * into selected_member
from public.classbot_members
where class_id = p_class_id
and status <> 'left'
and display_name = trim(p_display_name)
for update;
if selected_member.status = 'disabled' then
raise exception '비활성 구성원은 이름을 등록할 수 없습니다.';
end if;
if exists (
select 1 from public.classbot_members
where kakao_user_key = trim(p_user_key)
and id <> selected_member.id
) then
raise exception '이미 다른 구성원으로 등록된 카카오 계정입니다.';
end if;
if selected_member.kakao_user_key is not null
and selected_member.kakao_user_key <> trim(p_user_key) then
raise exception '이미 다른 카카오 계정에 등록된 이름입니다. 관리자에게 문의해 주세요.';
end if;
if selected_member.kakao_user_key = trim(p_user_key)
and selected_member.status = 'active' then
return next selected_member;
return;
end if;

update public.classbot_members
set kakao_user_key = trim(p_user_key),
kakao_user_key_type = p_user_key_type,
status = 'active',
joined_at = coalesce(joined_at, now()),
updated_at = now()
where class_id = p_class_id and id = selected_member.id
returning * into selected_member;
return next selected_member;
end;
$$;

revoke all on function public.classbot_claim_member_by_name(uuid, text, text, text) from public;
do $$
begin
if exists (select 1 from pg_roles where rolname = 'service_role') then
execute 'grant execute on function public.classbot_claim_member_by_name(uuid, text, text, text) to service_role';
end if;
end;
$$;

create table if not exists public.classbot_timetable (
id uuid primary key default gen_random_uuid(),
class_id uuid not null references public.classbot_classes(id) on delete cascade,
Expand Down Expand Up @@ -567,6 +663,20 @@ create unique index if not exists classbot_files_member_alias_idx
create index if not exists classbot_files_list_idx
on public.classbot_files(class_id, status, created_at desc);

create table if not exists public.classbot_kakao_states (
member_id uuid primary key references public.classbot_members(id) on delete cascade,
class_id uuid not null references public.classbot_classes(id) on delete cascade,
pending_file_ids text[] not null check (cardinality(pending_file_ids) between 1 and 3),
pending_expires_at timestamptz not null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
foreign key (class_id, member_id)
references public.classbot_members(class_id, id) on delete cascade
);

create index if not exists classbot_kakao_states_expiry_idx
on public.classbot_kakao_states(class_id, pending_expires_at);

create table if not exists public.classbot_notifications (
id uuid primary key default gen_random_uuid(),
class_id uuid not null references public.classbot_classes(id) on delete cascade,
Expand Down Expand Up @@ -628,6 +738,7 @@ begin
'classbot_events',
'classbot_notices',
'classbot_files',
'classbot_kakao_states',
'classbot_notifications'
]
loop
Expand All @@ -649,6 +760,7 @@ alter table public.classbot_member_timetable enable row level security;
alter table public.classbot_events enable row level security;
alter table public.classbot_notices enable row level security;
alter table public.classbot_files enable row level security;
alter table public.classbot_kakao_states enable row level security;
alter table public.classbot_notifications enable row level security;
alter table public.classbot_audit_logs enable row level security;

Expand Down
Loading
Loading