Skip to content
Closed
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
22 changes: 13 additions & 9 deletions apps/classbot/README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
# Quilo
# Quilo schedule

2학년 4반의 시간표, 수행평가·과제 일정, 반 공지와 카카오톡 알림을 관리하는 Quilo 내장 기능이다. 관리자 화면은 데스크톱과 모바일을 지원한다. 학생은 카카오 챗봇에서 이름을 질문 맨 뒤에 붙여 반 공통 일정과 본인의 개인 일정을 조회하며, 초대 코드로 가입한 뒤에는 알림 수신 여부도 바꿀 수 있다.
2학년 4반의 개인별 시간표, 수행평가·과제 일정, 반 공지와 카카오톡 조회를 관리하는 Quilo 내장 기능이다. 관리자와 학생 화면은 데스크톱·모바일을 지원한다. 학생은 최초 한 번 이름과 초대 코드로 본인 확인한 뒤 웹과 카카오에서 이름을 반복 입력하지 않아도 된다.

## 현재 구현 범위

- 오늘 대시보드, 일정, 시간표, 반 공지, 구성원, 알림 기록, 설정
- 최대 16명 구성원과 1회용 초대 코드 가입
- 카카오 챗봇 명령: `오늘 일정 학생 1`, `내일 시간표 학생 1`, `이번 주 수행평가 학생 1`, `과제 학생 1`, `공지`, `알림 설정`
- 조회 답변의 퀵 리플은 조회한 구성원 이름을 맨 뒤에 붙여 다음 질문을 바로 선택할 수 있게 한다.
- 관리자용 오늘 대시보드, 일정, 시간표, 반 공지, 자료실, 구성원, 알림 기록, 설정
- 학생용 월·주·일 캘린더, 개인별 시간표, 일정 추가, PDF·이미지 드라이브
- 최대 16명 구성원과 1회용 초대 코드 가입, 30일 HttpOnly 학생 포털 세션
- 카카오 챗봇 명령: `오늘 일정`, `다음 일정`, `이번 달 일정`, `이번 주 남은 일정`, `수행평가 과제 통합 요약`, `시간표 전체`, `자료 목록`, `PDF 별칭`, `이미지 별칭`
- 미등록 사용자는 질문 맨 뒤에 등록 이름을 붙일 수 있고, `이름등록 ABCD-EFGH`를 한 뒤에는 본인 조회에서 이름을 생략한다.
- 반 전체 일정과 구성원별 개인 일정, 개인 일정 대상자에게만 보내는 알림
- 학생은 자기 개인 일정만 생성·수정·삭제하고, 관리자 역할만 반 전체 일정을 생성·수정·삭제한다.
- 반 전체 자료와 본인 개인 자료만 노출하는 비공개 파일 저장소와 15분 열기·다운로드 링크
- 일정 마감·변경, 평일 아침 시간표, 반 공지 Event API 알림
- Event API 작업 결과 조회와 사용자별 성공·실패 기록
- 일정·공지 생성 멱등성, 명시적 실패 재시도, 감사 로그
Expand Down Expand Up @@ -41,7 +44,7 @@ npm run release:check

## 운영 배포

1. Supabase 프로젝트의 SQL 편집기에서 [`db/schema.sql`](./db/schema.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 All @@ -56,8 +59,9 @@ 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`
- 일정 조회: `오늘 일정 학생 1`처럼 등록된 구성원 이름을 항상 맨 뒤에 입력
- 학생 가입: 자동 알림이 필요할 때 관리자 화면에서 초대 코드를 만든 뒤 챗봇에 `가입 ABCD-EFGH` 입력
- 최초 이름 등록: 관리자 화면에서 받은 일회용 코드를 챗봇에 `이름등록 ABCD-EFGH`로 입력
- 등록 후 조회: `오늘 일정`, `내일 시간표`, `시간표 전체`, `자료 목록`처럼 이름 없이 사용
- 명시적 대상 조회: 미등록 상태이거나 다른 구성원의 공개 일정을 볼 때만 `오늘 일정 등록이름`처럼 이름을 맨 뒤에 입력

Event API의 POST 성공은 접수 성공일 뿐 실제 전송 완료가 아니다. 이 서비스는 `taskId` 결과 조회 후에만 `sent`로 기록하며, 실패는 자동 재시도하지 않는다. 상세 운영 정책은 [`server/README.md`](./server/README.md), 공식 연동 근거와 열품타 정책은 [`docs/integrations.md`](./docs/integrations.md)를 참고한다.

Expand Down
42 changes: 42 additions & 0 deletions apps/classbot/db/migrations/002_files.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
begin;

create table if not exists public.classbot_files (
id uuid primary key default gen_random_uuid(),
class_id uuid not null references public.classbot_classes(id) on delete cascade,
member_id uuid references public.classbot_members(id) on delete cascade,
alias text not null check (char_length(alias) between 1 and 60),
filename text not null check (char_length(filename) between 1 and 180),
description text not null default '' check (char_length(description) <= 1000),
mime_type text not null check (mime_type in ('application/pdf', 'image/jpeg', 'image/png', 'image/webp', 'image/gif')),
size_bytes bigint not null check (size_bytes between 1 and 20971520),
bucket text not null,
object_path text not null unique,
status text not null default 'active' check (status in ('active', 'deleted')),
created_by text not null default 'admin',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);

create unique index if not exists classbot_files_class_alias_idx
on public.classbot_files(class_id, lower(alias))
where member_id is null and status = 'active';

create unique index if not exists classbot_files_member_alias_idx
on public.classbot_files(class_id, member_id, lower(alias))
where member_id is not null and status = 'active';

create index if not exists classbot_files_list_idx
on public.classbot_files(class_id, status, created_at desc);

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

alter table public.classbot_files enable row level security;

insert into public.classbot_schema_meta(id, version, applied_at)
values (1, 2, now())
on conflict (id) do update set version = excluded.version, applied_at = excluded.applied_at;

commit;
159 changes: 159 additions & 0 deletions apps/classbot/db/migrations/003_member_timetable.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
-- Classbot schema v3: per-member timetables.
-- Run with the Supabase service/database owner role. This migration contains
-- no roster or timetable data and is safe to re-run.

do $$
begin
if not exists (
select 1 from pg_constraint
where conname = 'classbot_members_class_id_id_key'
and conrelid = 'public.classbot_members'::regclass
) then
alter table public.classbot_members
add constraint classbot_members_class_id_id_key unique (class_id, id);
end if;
end;
$$;

create table if not exists public.classbot_member_timetable (
id uuid primary key default gen_random_uuid(),
class_id uuid not null,
member_id uuid not null,
weekday integer not null check (weekday between 1 and 5),
period integer not null check (period between 1 and 12),
subject text not null check (char_length(trim(subject)) between 1 and 100),
activity text not null default '' check (char_length(activity) <= 300),
teacher text not null default '' check (char_length(teacher) <= 100),
room text not null default '' check (char_length(room) <= 100),
memo text not null default '' check (char_length(memo) <= 500),
effective_from date not null default current_date,
effective_to date,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint classbot_member_timetable_member_fkey
foreign key (class_id, member_id)
references public.classbot_members(class_id, id)
on delete cascade,
constraint classbot_member_timetable_effective_dates_check
check (effective_to is null or effective_to >= effective_from),
constraint classbot_member_timetable_slot_key
unique (class_id, member_id, weekday, period, effective_from)
);

create index if not exists classbot_member_timetable_lookup_idx
on public.classbot_member_timetable(class_id, member_id, weekday, effective_from, effective_to, period);

create or replace function public.classbot_replace_member_timetable(
p_class_id uuid,
p_member_id uuid,
p_rows jsonb
)
returns setof public.classbot_member_timetable
language plpgsql
security definer
set search_path = public
as $$
begin
if jsonb_typeof(coalesce(p_rows, '[]'::jsonb)) <> 'array' then
raise exception '개인 시간표 데이터는 배열이어야 합니다.';
end if;

perform 1
from public.classbot_members
where class_id = p_class_id
and id = p_member_id
and status <> 'left'
for update;
if not found then
raise exception '개인 시간표를 등록할 구성원을 찾을 수 없습니다.';
end if;

if exists (
select 1
from jsonb_to_recordset(coalesce(p_rows, '[]'::jsonb)) as row_data(
weekday integer,
period integer,
subject text,
activity text,
teacher text,
room text,
memo text,
effective_from date,
effective_to date
)
where row_data.weekday is null
or row_data.weekday < 1
or row_data.weekday > 5
or row_data.period is null
or row_data.period < 1
or row_data.period > 12
or nullif(trim(row_data.subject), '') is null
or char_length(trim(row_data.subject)) > 100
or char_length(coalesce(row_data.activity, '')) > 300
or char_length(coalesce(row_data.teacher, '')) > 100
or char_length(coalesce(row_data.room, '')) > 100
or char_length(coalesce(row_data.memo, '')) > 500
or (
row_data.effective_to is not null
and row_data.effective_to < coalesce(row_data.effective_from, current_date)
)
) then
raise exception '개인 시간표 행 형식이 올바르지 않습니다.';
end if;

delete from public.classbot_member_timetable
where class_id = p_class_id and member_id = p_member_id;

return query
with inserted as (
insert into public.classbot_member_timetable(
class_id, member_id, weekday, period, subject, activity, teacher, room, memo, effective_from, effective_to
)
select
p_class_id,
p_member_id,
row_data.weekday,
row_data.period,
trim(row_data.subject),
coalesce(trim(row_data.activity), ''),
coalesce(trim(row_data.teacher), ''),
coalesce(trim(row_data.room), ''),
coalesce(trim(row_data.memo), ''),
coalesce(row_data.effective_from, current_date),
row_data.effective_to
from jsonb_to_recordset(coalesce(p_rows, '[]'::jsonb)) as row_data(
weekday integer,
period integer,
subject text,
activity text,
teacher text,
room text,
memo text,
effective_from date,
effective_to date
)
returning *
)
select * from inserted order by weekday, period, effective_from desc;
end;
$$;

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

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

alter table public.classbot_member_timetable enable row level security;

insert into public.classbot_schema_meta(id, version, applied_at)
values (1, 3, now())
on conflict (id) do update set version = excluded.version, applied_at = excluded.applied_at;
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;
Loading
Loading