Staff Manager のデータモデル・アーキテクチャ・5 フェーズ商品化ロードマップ。2026-05-09 礒貝CEO 決裁 により、自前 SaaS として AWS 上で企業向け展開する方針が確定済み。
当初は「LocalStorage プロトタイプ → Notion バックエンドに本番移行」というプランで進めていましたが、世界 78カ国マルチテナント展開・課金・SOC2 までを見据えた結果、自前 SaaS として AWS 上で育てる 方針に転換しました。
自前 DB + 自前認証で SaaS 化。UI 自由度・課金・マルチテナント・ブランド独立性すべてを取りに行く。
UI を廃棄し、Notion DB に全移行。短期は楽だが、ブランド・課金・マルチテナントを Notion に握られる。
フロントは自前、Notion をバックエンド DB として併用。
| Phase | 期間 | 内容 | ゴール |
|---|---|---|---|
| 1. プロトタイプ完成 | 〜数週間 | UI/UX・運用フロー確定 (LocalStorage のまま) | 「これで運用できる」確信を礒貝CEOが持つ |
| 2. バックエンド導入 | 1-2週間 | Supabase (Postgres + Auth + Realtime + RLS) | データ永続化、複数デバイス同期、$0/月 |
| 3. マルチテナント化 | 1-2週間 | tenant_id + Postgres RLS で会社単位完全分離 |
他社にも貸せる SaaS 状態 |
| 4. AWS 移行 | 2-4週間 | Aurora Serverless v2 + Cognito + Lambda + CDK | 本番 SLA、数千社スケール |
| 5. 商品化 | 継続 | Stripe 課金 / SOC2 / オンボーディング / マーケサイト | エンタープライズ正式販売開始 |
| レイヤー | Phase 2-3 | Phase 4 (本番) |
|---|---|---|
| Frontend | 静的 HTML/JS (現状) → React or Next.js | S3 + CloudFront |
| API | Supabase Edge Functions | API Gateway + Lambda (Node/TypeScript) |
| DB | Supabase Postgres | Aurora Serverless v2 (Postgres 互換) |
| Auth | Supabase Auth + Google federation | AWS Cognito + Google federation |
| Storage | Supabase Storage | S3 + CloudFront |
| Realtime | Supabase Realtime | AppSync (または polling) |
| IaC | Phase 4 で本格化 | AWS CDK (TypeScript) |
| CI/CD | GitHub Actions → Supabase | GitHub Actions → AWS (OIDC) |
マスター系 2 (employees / projects) + トランザクション系 4 (tasks / daily_reports / time_blocks / leave_requests)。全テーブルに tenant_id (UUID) を Phase 3 で追加し、Postgres Row Level Security で会社単位完全分離します。
| カラム名 | 型 | 用途・候補値 |
|---|---|---|
| id | UUID PK | 自動採番 |
| tenant_id | UUID FK | tenants.id 参照(Phase 3 追加) |
| name | TEXT | 「礒貝 浩資」 |
| kana | TEXT | 「いそがい ひろし」 |
| role | TEXT | CEO / CFO / CTO / COO / CMO / CSO / CHRO / 一般 |
| dept | TEXT | 経営 / 財務 / 技術 / 業務 / マーケ / 営業 / 人事 |
| CITEXT | xxx@ganecci.net 社用メール | |
| gmail | CITEXT | 個人 Gmail (Google ログインで両方マッチ) |
| joined_at | DATE | 入社日 |
| status | TEXT | active / leave / retired |
| color | TEXT | アバター背景用 (10色) |
| role_level | SMALLINT | 1=社長(LV.MAX) / 2=管理者(LV.MIDDLE) / 3=一般(STANDARD) |
| cognito_sub | TEXT | AWS Cognito 紐付け用 sub クレーム(Phase 4) |
| google_calendar_id | TEXT | カレンダー API 取得対象 |
| created_at / updated_at | TIMESTAMPTZ | — |
| カラム名 | 型 | 用途 |
|---|---|---|
| id | UUID PK | — |
| tenant_id | UUID FK | tenants.id 参照 |
| name | TEXT | 「SightClean」「isoAi」「世界78カ国展開」など |
| status | TEXT | idea / active / paused / done |
| lead_emp_id | UUID FK | employees.id (プロジェクト責任者) |
| starts_on | DATE | 開始日 |
| due_on | DATE | 期限 |
| color | TEXT | UI 上の色分け |
| created_at / updated_at | TIMESTAMPTZ | — |
project_members(project_id, emp_id) 中間テーブルで管理(Phase 2 で追加)。| カラム名 | 型 | 用途 |
|---|---|---|
| id | UUID PK | — |
| tenant_id | UUID FK | tenants.id 参照 |
| title | TEXT | — |
| assignee_emp_id | UUID FK | employees.id (担当者 1名) |
| project_id | UUID FK | projects.id (1件) |
| priority | TEXT | high / mid / low |
| status | TEXT | todo / doing / done / blocked |
| due_start | DATE | 期間タスクの開始日 |
| due_at | DATE | 期限 |
| start_time / end_time | TIME | 時間範囲(任意) |
| effort_h | NUMERIC(5,2) | 工数 (h) |
| progress | SMALLINT | 0〜100 進捗率 |
| parent_task_id | UUID self-FK | 階層化用 |
| related_report_id | UUID FK | daily_reports.id |
| assigned_by_emp_id | UUID FK | 委任元(自分以外なら依頼) |
| acknowledged_at | TIMESTAMPTZ | 受領日時 |
| created_at / updated_at | TIMESTAMPTZ | — |
| カラム名 | 型 | 用途 |
|---|---|---|
| id | UUID PK | — |
| tenant_id | UUID FK | — |
| emp_id | UUID FK | employees.id |
| report_date | DATE | — |
| done_today | TEXT | 今日やったこと |
| do_tomorrow | TEXT | 明日やること |
| blockers | TEXT | 課題・ブロッカー |
| mood | SMALLINT 1-5 | 5=絶好調 / 4=良好 / 3=普通 / 2=やや不調 / 1=不調 |
| submitted_at | TIMESTAMPTZ | — |
(tenant_id, emp_id, report_date) の複合 UNIQUE で、同一テナント×同一従業員×同一日は 1 件のみ(DB レベルで担保)。
| カラム名 | 型 | 用途 |
|---|---|---|
| id | UUID PK | — |
| tenant_id | UUID FK | — |
| emp_id | UUID FK | — |
| title | TEXT | 「朝会」「コーディング」など |
| block_date | DATE | — |
| starts_at / ends_at | TIME | 開始/終了 時刻 |
| description | TEXT | — |
| project_id | UUID FK | 任意 |
| kind | TEXT | work / meeting / travel / break / focus |
| created_at | TIMESTAMPTZ | — |
time_blocks は当アプリ内のタイムブロック管理用。同時に Google Calendar API で取得した個人予定を別キャッシュ gcal_event_cache(Phase 2)に保存して、「アプリで作ったブロック」と「Google で作った予定」を区別表示します。
| カラム名 | 型 | 用途 |
|---|---|---|
| id | UUID PK | — |
| tenant_id | UUID FK | — |
| applicant_emp_id | UUID FK | employees.id |
| starts_on / ends_on | DATE | 単日 or 範囲 |
| kind | TEXT | paid / sick / special / condolence |
| reason | TEXT | — |
| status | TEXT | pending / approved / rejected |
| approver_emp_id | UUID FK | 承認者 (CHRO / CEO) |
| decided_at | TIMESTAMPTZ | 承認/却下時刻 |
| created_at | TIMESTAMPTZ | 申請日時 |
「他社にも貸せる」状態を Phase 3 で達成。tenants テーブル新設 + 全テーブルに tenant_id + Postgres Row Level Security で会社単位完全分離します。tenant_id を後付けで足すと地獄になるため、データモデルの初期段階から織り込み済み。
| カラム名 | 型 | 用途 |
|---|---|---|
| id | UUID PK | 自動採番 |
| name | TEXT | 「株式会社GANECCI」など会社名 |
| plan | TEXT | free / starter / business / enterprise |
| settings | JSONB | テナント単位設定(タイムゾーン、ブランド色、etc.) |
| created_at | TIMESTAMPTZ | — |
JWT クレームから tenant_id を取り出し、API ミドルウェア(Edge Function or Lambda)で Postgres セッション変数に設定 → RLS が自動でフィルタする。
-- API リクエスト先頭で実行(JWT から取り出した tenant_id を埋め込む)
SET app.tenant_id = '550e8400-e29b-41d4-a716-446655440000';
-- 各テーブルで RLS を有効化 + ポリシー設定
ALTER TABLE employees ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_employees ON employees
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
-- 結果: 以降の SELECT/UPDATE/DELETE は自動的に
-- WHERE tenant_id = '550e8400-...' でフィルタされる
auth.jwt() -> 'tenant_id' を直接 RLS ポリシーで参照できるため、SET 文不要。SET app.tenant_id を実行する pattern が一般的。
role_level (1/2/3) を employees テーブルに保持。アプリ層 + API 層の両方で検証。RLS だけでは「自分のデータのみ」までしか守れないので、「LV.MIDDLE は他人の電話番号も見える」のような細かい制御は API 層(認可ミドルウェア)で実装します。
| role_level | 呼称 | employees | tasks | daily_reports | leave_requests |
|---|---|---|---|---|---|
| 1 | LV.MAX 社長 (CEO) | 全閲覧編集 | 全閲覧編集 | 全閲覧 | 全閲覧承認 |
| 2 | LV.MIDDLE 管理者 (役員) | 全閲覧 / 自分編集 | 全閲覧 / 自分編集 | 全閲覧 | 全閲覧承認 |
| 3 | STANDARD 一般 | 自分のみ | 自分のみ編集 | 自分のみ作成 / 全閲覧 | 自分申請のみ |
以下の DDL を Supabase SQL Editor で実行すれば Phase 2 のスキーマが構築されます。Phase 4 で Aurora Serverless v2 に移行する際も pg_dump でそのまま持ち込めます。
-- ===== 拡張機能 =====
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "citext";
-- ===== tenants =====
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
plan TEXT NOT NULL DEFAULT 'free' CHECK (plan IN ('free','starter','business','enterprise')),
settings JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ===== employees =====
CREATE TABLE employees (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
kana TEXT,
role TEXT NOT NULL,
dept TEXT,
email CITEXT NOT NULL,
gmail CITEXT,
joined_at DATE,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','leave','retired')),
color TEXT,
role_level SMALLINT NOT NULL DEFAULT 3 CHECK (role_level IN (1,2,3)),
cognito_sub TEXT,
google_calendar_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, email)
);
CREATE INDEX idx_employees_tenant ON employees(tenant_id);
-- ===== projects =====
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('idea','active','paused','done')),
lead_emp_id UUID REFERENCES employees(id),
starts_on DATE,
due_on DATE,
color TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_projects_tenant ON projects(tenant_id);
-- ===== tasks =====
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
title TEXT NOT NULL,
assignee_emp_id UUID REFERENCES employees(id),
project_id UUID REFERENCES projects(id),
priority TEXT NOT NULL DEFAULT 'mid' CHECK (priority IN ('high','mid','low')),
status TEXT NOT NULL DEFAULT 'todo' CHECK (status IN ('todo','doing','done','blocked')),
due_start DATE,
due_at DATE,
start_time TIME,
end_time TIME,
effort_h NUMERIC(5,2),
progress SMALLINT NOT NULL DEFAULT 0 CHECK (progress BETWEEN 0 AND 100),
parent_task_id UUID REFERENCES tasks(id),
related_report_id UUID,
assigned_by_emp_id UUID REFERENCES employees(id),
acknowledged_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_tasks_tenant_assignee ON tasks(tenant_id, assignee_emp_id);
CREATE INDEX idx_tasks_due ON tasks(tenant_id, due_at);
-- ===== daily_reports =====
CREATE TABLE daily_reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
emp_id UUID NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
report_date DATE NOT NULL,
done_today TEXT,
do_tomorrow TEXT,
blockers TEXT,
mood SMALLINT CHECK (mood BETWEEN 1 AND 5),
submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, emp_id, report_date)
);
CREATE INDEX idx_reports_date ON daily_reports(tenant_id, report_date);
-- ===== time_blocks =====
CREATE TABLE time_blocks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
emp_id UUID NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
title TEXT NOT NULL,
block_date DATE NOT NULL,
starts_at TIME NOT NULL,
ends_at TIME NOT NULL,
description TEXT,
project_id UUID REFERENCES projects(id),
kind TEXT CHECK (kind IN ('work','meeting','travel','break','focus')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_blocks_emp_date ON time_blocks(tenant_id, emp_id, block_date);
-- ===== leave_requests =====
CREATE TABLE leave_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
applicant_emp_id UUID NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
starts_on DATE NOT NULL,
ends_on DATE NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('paid','sick','special','condolence')),
reason TEXT,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','rejected')),
approver_emp_id UUID REFERENCES employees(id),
decided_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_leaves_status ON leave_requests(tenant_id, status);
-- ===== Row Level Security =====
ALTER TABLE employees ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
ALTER TABLE daily_reports ENABLE ROW LEVEL SECURITY;
ALTER TABLE time_blocks ENABLE ROW LEVEL SECURITY;
ALTER TABLE leave_requests ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_iso_employees ON employees USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
CREATE POLICY tenant_iso_projects ON projects USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
CREATE POLICY tenant_iso_tasks ON tasks USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
CREATE POLICY tenant_iso_daily_reports ON daily_reports USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
CREATE POLICY tenant_iso_time_blocks ON time_blocks USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
CREATE POLICY tenant_iso_leave_requests ON leave_requests USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
-- ===== シードデータ(GANECCI テナント)=====
INSERT INTO tenants (id, name, plan)
VALUES ('00000000-0000-0000-0000-00000000ga01', '株式会社GANECCI', 'enterprise');
-- 以降、employees / projects / etc. に上記 tenant_id を入れて投入
signInWithOAuth({ provider: 'google' }) で動く。employees 行を upsert するトリガーを設定(auth.users → employees マッピング)。localStorage ↔ loadData() / saveData()supabase.from('tasks').select('*') ベースに置換。app.js の STAFF / PROJECTS 配列に対応するレコードを INSERT。tenant_id は GANECCI テナントの UUID。auth.users.raw_app_meta_data.tenant_id を JWT に追加。RLS が自動で動き出す。@ganecci.net)の Workspace アカウントでログインemployees.gmail でマッチング)本アプリの time_blocks はアプリ内で作成するタイムブロック専用。個人の予定は Google Calendar API で取得して 「Google カレンダー = 1次ソース」「アプリ DB = 集計・分析レイヤー」 の役割分担にします。
| 用途 | 置き場所 | 理由 |
|---|---|---|
| 個人の予定 (会議等) | Google カレンダー | 既存の習慣 / モバイル通知 / 外部連携が強い |
| 出社/退社の打刻 | Postgres (leave_requests) | 集計が必要 |
| プロジェクトごとの工数 | Postgres (tasks.effort_h) | レポート要件 |
| 日報 | Postgres (daily_reports) | 検索・横断分析 |
| タイムブロック | Postgres (time_blocks) | アプリ内 UI で完結 |
Google Calendar API で取得したイベントを当アプリ UI に統合表示。実装は google-auth.js の fetchCalendarEvents(calendarId) / fetchManyCalendars(ids):
詳細手順は 📧 招待・参加フロー を参照。
ganecci.net Workspace アカウント発行 (推奨)