Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
58fd199
docs: マルチテナント化概要設計ドキュメントを追加
jujunjun110 Jan 14, 2026
c9f46b5
docs: レビュー指摘を反映
jujunjun110 Jan 14, 2026
0148c6a
fix: CodeRabbitレビュー指摘を反映
jujunjun110 Jan 14, 2026
6ebd19c
docs: デフォルトテナント/政治団体選択の永続化フローを追加
jujunjun110 Jan 15, 2026
f75b76a
docs: TenantRoleをconst配列+型ガードパターンに変更
jujunjun110 Jan 15, 2026
221366a
docs: フェーズ3にRLSポリシー検証ステップを追加
jujunjun110 Jan 15, 2026
8af3e21
docs: マルチテナント環境でのキャッシュ戦略を追加
jujunjun110 Jan 15, 2026
a73ca01
docs: user_preferencesテーブルを削除してシンプル化
jujunjun110 Jan 15, 2026
0d477b4
Merge pull request #1117 from team-mirai/docs/multi-tenant-design
jujunjun110 Jan 15, 2026
a490a36
docs: フェーズ1マルチテナント化の詳細設計手順書を追加
claude Jan 15, 2026
9d42f69
Merge pull request #1118 from team-mirai/claude/phase-1-design-spec-4…
jujunjun110 Jan 15, 2026
4e38670
feat: フェーズ1マルチテナント化のスキーマ・ドメインモデル追加
jujunjun110 Jan 15, 2026
cc068b4
refactor: メンバーシップリポジトリのマッピングロジックをヘルパーに抽出
jujunjun110 Jan 15, 2026
4a10ca1
Merge pull request #1119 from team-mirai/feature/phase1-multi-tenant-…
jujunjun110 Jan 15, 2026
b9e12d4
docs: メンテナンスモード設計ドキュメントを追加
claude Jan 15, 2026
ed9d508
docs: page.tsx不要である点を明記
claude Jan 15, 2026
e749f4e
docs: フェーズ2マルチテナント化の詳細設計手順書を追加
jujunjun110 Jan 15, 2026
da23da1
docs: HTMLテンプレートを別ファイルに分離する構成を追記
claude Jan 15, 2026
22db363
fix: フェーズ2設計書のProvider実装とTenantMembershipWithTenant参照を修正
jujunjun110 Jan 15, 2026
9c26edb
docs: 環境変数変更後は再デプロイが必要である点を修正
claude Jan 15, 2026
9b51c36
docs: MAINTENANCE_MESSAGE環境変数を追加
claude Jan 15, 2026
d8f7f0a
docs: React.cache使用の意図をコメントで明確化
jujunjun110 Jan 15, 2026
ebb12fa
refactor: ページ内のRepository直接インスタンス化を専用Loaderに置き換え
jujunjun110 Jan 15, 2026
5c552d8
Merge pull request #1121 from team-mirai/claude/maintenance-mode-plan…
jujunjun110 Jan 15, 2026
b6d61c1
feat: メンテナンスモード機能を実装
devin-ai-integration[bot] Jan 15, 2026
7331b79
fix: proxy.tsにメンテナンスモードを統合(middleware.tsとの競合を解消)
devin-ai-integration[bot] Jan 15, 2026
f155f23
Merge pull request #1122 from team-mirai/devin/1768448664-maintenance…
jujunjun110 Jan 15, 2026
eee182b
chore: .env.exampleを整理
jujunjun110 Jan 15, 2026
979cb25
Merge pull request #1120 from team-mirai/docs/phase2-multi-tenant-design
jujunjun110 Jan 15, 2026
50cbff1
refactor: maintenance-htmlをsrc/client/templatesに移動
jujunjun110 Jan 15, 2026
18d6bde
chore: .env.exampleからダブルクオーテーションを削除
devin-ai-integration[bot] Jan 15, 2026
1ee46b6
Merge pull request #1124 from team-mirai/refactor/move-maintenance-ht…
jujunjun110 Jan 15, 2026
df57f25
chore: SUPABASE_ANON_KEYとSUPABASE_SERVICE_ROLE_KEYにダブルクオーテーションを追加
devin-ai-integration[bot] Jan 15, 2026
611ff68
Merge pull request #1123 from team-mirai/chore/cleanup-env-example
jujunjun110 Jan 15, 2026
5f58fb1
fix(ci): Supabase CLIの動的キー生成に対応
devin-ai-integration[bot] Jan 19, 2026
158f341
fix(ci): workflowの変更でもCIが実行されるようにパスフィルターを追加
devin-ai-integration[bot] Jan 19, 2026
19ca036
Merge pull request #1131 from team-mirai/devin/1768786752-fix-supabas…
jujunjun110 Jan 19, 2026
ad2f35e
fix: ルートアクセス時のデフォルト組織をdisplayName降順の最初の組織に変更
claude Feb 4, 2026
7704e67
Merge pull request #1138 from team-mirai/claude/investigate-root-redi…
jujunjun110 Feb 4, 2026
cb973f8
chore(deps): update dependency @next/third-parties to v16.1.6
renovate[bot] Feb 12, 2026
21c7729
chore(deps): update pnpm to v10.30.3
renovate[bot] Feb 26, 2026
50e95a1
chore(deps): update ai-sdk
renovate[bot] Mar 5, 2026
947697c
chore(deps): update dependency dependency-cruiser to v17.3.8
renovate[bot] Mar 5, 2026
a114572
chore(deps): update types
renovate[bot] Mar 6, 2026
bd225da
fix: 「政治資金規制法」→「政治資金規正法」の誤字修正、「仕訳け」→「仕訳」の修正
claude Mar 7, 2026
5b1fb4d
Merge pull request #1146 from team-mirai/claude/fix-issue-1145-TAJ5e
jujunjun110 Mar 7, 2026
1dcd31e
Merge pull request #1126 from team-mirai/renovate/ai-sdk
jujunjun110 Mar 7, 2026
b0f8cc8
Merge pull request #1127 from team-mirai/renovate/nextjs-monorepo
jujunjun110 Mar 7, 2026
61a5444
Merge pull request #1128 from team-mirai/renovate/types
jujunjun110 Mar 7, 2026
61be91e
Merge pull request #1133 from team-mirai/renovate/dependency-cruiser-…
jujunjun110 Mar 7, 2026
62e7688
Merge pull request #1135 from team-mirai/renovate/pnpm-10.x
jujunjun110 Mar 7, 2026
ede31df
chore(deps): update dependency zod to v4.3.6
renovate[bot] Mar 7, 2026
bfeea3e
Merge pull request #1134 from team-mirai/renovate/zod-4.x-lockfile
jujunjun110 Mar 7, 2026
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
50 changes: 29 additions & 21 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ on:
- 'prisma/**'
- '.dependency-cruiser.cjs'
- 'pnpm-lock.yaml'
- '.github/workflows/**'

env:
# Supabase local development defaults (public, not secrets)
# https://supabase.com/docs/guides/local-development/auth#log-in-as-an-existing-user
# Supabase local development defaults
# Note: SUPABASE_ANON_KEY and SUPABASE_SERVICE_ROLE_KEY are now dynamically generated
# by Supabase CLI and will be extracted after `supabase start` in the e2e job
SUPABASE_URL: http://127.0.0.1:54321
SUPABASE_ANON_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
SUPABASE_SERVICE_ROLE_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
# Note: CI uses default Supabase ports (54321 for API, 54322 for DB)
# Local development may use different ports - check supabase/config.toml
DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:54322/postgres
Expand Down Expand Up @@ -250,47 +250,55 @@ jobs:
- name: Generate Prisma Client
run: pnpm db:generate

- name: Start Supabase and build admin in parallel
- name: Start Supabase
run: |
# Enable API and use default ports for CI
sed -i 's/^\[api\]$/[api]/' supabase/config.toml
sed -i '/^\[api\]$/,/^\[/{s/^enabled = false$/enabled = true/}' supabase/config.toml
sed -i 's/^port = 54332$/port = 54322/' supabase/config.toml

# Start Supabase in background
supabase start --exclude imgproxy,edge-runtime,vector,studio &
SUPABASE_PID=$!

# Build admin in parallel (admin doesn't need DB)
NEXT_PUBLIC_SUPABASE_URL=${{ env.SUPABASE_URL }} \
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ env.SUPABASE_ANON_KEY }} \
pnpm build:admin &
ADMIN_PID=$!

# Wait for admin build to complete
wait $ADMIN_PID

# Wait for Supabase to complete
wait $SUPABASE_PID
# Start Supabase
supabase start --exclude imgproxy,edge-runtime,vector,studio

# Wait for Supabase API to be ready
echo "Waiting for Supabase API..."
timeout 60 bash -c 'until curl -s http://127.0.0.1:54321/rest/v1/ > /dev/null 2>&1; do sleep 2; done'
echo "Supabase is ready"

- name: Get Supabase keys
id: supabase-keys
run: |
# Extract dynamic keys from Supabase CLI
SUPABASE_STATUS=$(supabase status --output json)
ANON_KEY=$(echo "$SUPABASE_STATUS" | jq -r '.ANON_KEY')
SERVICE_ROLE_KEY=$(echo "$SUPABASE_STATUS" | jq -r '.SERVICE_ROLE_KEY')

echo "SUPABASE_ANON_KEY=$ANON_KEY" >> $GITHUB_OUTPUT
echo "SUPABASE_SERVICE_ROLE_KEY=$SERVICE_ROLE_KEY" >> $GITHUB_OUTPUT

# Also set as environment variables for subsequent steps
echo "SUPABASE_ANON_KEY=$ANON_KEY" >> $GITHUB_ENV
echo "SUPABASE_SERVICE_ROLE_KEY=$SERVICE_ROLE_KEY" >> $GITHUB_ENV

- name: Run database migrations and seed
run: |
pnpm db:migrate:deploy
pnpm db:seed

- name: Build admin
run: pnpm build:admin
env:
NEXT_PUBLIC_SUPABASE_URL: ${{ env.SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ steps.supabase-keys.outputs.SUPABASE_ANON_KEY }}

- name: Build webapp
run: pnpm build:webapp

- name: Run E2E tests for admin
run: pnpm --filter admin test:e2e
env:
NEXT_PUBLIC_SUPABASE_URL: ${{ env.SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ env.SUPABASE_ANON_KEY }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ steps.supabase-keys.outputs.SUPABASE_ANON_KEY }}

- name: Run E2E tests for webapp
run: pnpm --filter webapp test:e2e
Expand Down
24 changes: 12 additions & 12 deletions admin/.env.example
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
# copy to .env.local for development

# === 必須 ===
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54332/postgres

SUPABASE_URL=http://127.0.0.1:54321
SUPABASE_ANON_KEY="your anon key from supabase status"
SUPABASE_SERVICE_ROLE_KEY="your service key from supabase status"

# Cache refresh
DATA_REFRESH_TOKEN="your-secret-refresh-token"
WEBAPP_URL="http://localhost:3000"
SITE_URL="http://localhost:3001"
DATA_REFRESH_TOKEN=your-secret-refresh-token
WEBAPP_URL=http://localhost:3000
SITE_URL=http://localhost:3001

# Basic Authentication (optional)
# If set, enables basic auth for the entire application
# Value should be SHA256 hash of "id:password" format
# Example: echo -n "admin:password" | shasum -a 256
# BASIC_AUTH_SECRET="sha256_hash_of_id_password"
# === 任意 ===
# MAINTENANCE_MODE=true
# MAINTENANCE_MESSAGE=予定時間 22:00-26:00

# Database Configuration
DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:54332/postgres"
DIRECT_URL="postgresql://postgres:postgres@127.0.0.1:54332/postgres"
# ベーシック認証: echo -n "admin:password" | shasum -a 256
# BASIC_AUTH_SECRET=sha256_hash_of_id_password
76 changes: 76 additions & 0 deletions admin/src/client/templates/maintenance-html.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
export function getMaintenanceHtml(message?: string): string {
const additionalMessage = message
? `<p style="margin-top: 12px; font-size: 13px; color: #6b7280;">${escapeHtml(message)}</p>`
: "";

return `<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>メンテナンス中 | 管理画面</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Hiragino Sans", "Noto Sans CJK JP", sans-serif;
background: #f3f4f6;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 32px 24px;
max-width: 400px;
width: 100%;
text-align: center;
}
h1 {
font-size: 18px;
font-weight: 600;
color: #111827;
margin-bottom: 12px;
}
p {
font-size: 14px;
color: #4b5563;
line-height: 1.5;
}
.footer {
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid #e5e7eb;
font-size: 12px;
color: #9ca3af;
}
</style>
</head>
<body>
<div class="container">
<h1>メンテナンス中</h1>
<p>現在、システムメンテナンスを実施しております。<br>しばらくお待ちください。</p>
${additionalMessage}
<div class="footer">政治資金ダッシュボード管理画面</div>
</div>
</body>
</html>`;
}

function escapeHtml(text: string): string {
const map: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
};
return text.replace(/[&<>"']/g, (char) => map[char]);
}
16 changes: 16 additions & 0 deletions admin/src/proxy.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createServerClient, type CookieOptions } from "@supabase/ssr";
import { type NextRequest, NextResponse } from "next/server";
import { getMaintenanceHtml } from "@/client/templates/maintenance-html";

async function hashCredentials(credentials: string): Promise<string> {
const encoder = new TextEncoder();
Expand Down Expand Up @@ -48,6 +49,21 @@ async function performBasicAuth(request: NextRequest): Promise<NextResponse | nu

// publicパス以外は全て認証を必須とする(セキュアデフォルト)
export async function proxy(request: NextRequest): Promise<NextResponse> {
// メンテナンスモードチェック
const isMaintenanceMode = process.env.MAINTENANCE_MODE === "true";
if (isMaintenanceMode) {
const maintenanceMessage = process.env.MAINTENANCE_MESSAGE;
const html = getMaintenanceHtml(maintenanceMessage);
return new NextResponse(html, {
status: 503,
headers: {
"Content-Type": "text/html; charset=utf-8",
"Retry-After": "3600",
"Cache-Control": "no-store, no-cache, must-revalidate",
},
});
}

// PKCEフローでのパスワードリセット: codeパラメータがルートURLに来た場合は/loginにリダイレクト
const code = request.nextUrl.searchParams.get("code");
if (code && request.nextUrl.pathname === "/") {
Expand Down
53 changes: 53 additions & 0 deletions admin/src/server/contexts/auth/domain/models/tenant-role.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* テナントロール(ドメイン層の型定義)
*
* const配列 + 型ガード パターンで実装
* - 新規実装では interface + const パターンを使用するが、
* enumはPrismaスキーマと同期するため特別扱い
*/

/** テナントロールの値一覧 */
export const TENANT_ROLES = ["owner", "admin", "editor"] as const;

/** テナントロール型 */
export type TenantRole = (typeof TENANT_ROLES)[number];

/**
* TenantRole のドメインモデル
*/
export const TenantRoleModel = {
/**
* 値がTenantRoleかどうかを検証
*/
isTenantRole(value: unknown): value is TenantRole {
return typeof value === "string" && TENANT_ROLES.includes(value as TenantRole);
},

/**
* 指定されたロールが必要な権限を持っているか判定
* @param currentRole 現在のロール
* @param requiredRole 必要なロール
*/
hasPermission(currentRole: TenantRole, requiredRole: TenantRole): boolean {
const hierarchy: Record<TenantRole, number> = {
owner: 3,
admin: 2,
editor: 1,
};
return hierarchy[currentRole] >= hierarchy[requiredRole];
},

/**
* owner権限を持っているか判定
*/
isOwner(role: TenantRole): boolean {
return role === "owner";
},

/**
* admin以上の権限を持っているか判定
*/
isAdminOrAbove(role: TenantRole): boolean {
return role === "owner" || role === "admin";
},
} as const;
43 changes: 43 additions & 0 deletions admin/src/server/contexts/auth/domain/models/tenant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import "server-only";

/**
* テナント(契約・管理単位)
* 政党や個人議員事務所など、利用者の最上位の管理単位
*/
export interface Tenant {
id: bigint;
name: string;
slug: string;
description: string | null;
createdAt: Date;
updatedAt: Date;
}

/**
* Tenant のドメインモデル
*/
export const TenantModel = {
/**
* slug のバリデーション
* - 英小文字、数字、ハイフンのみ
* - 3〜50文字
*/
validateSlug(slug: string): { valid: boolean; message?: string } {
if (slug.length < 3 || slug.length > 50) {
return { valid: false, message: "slugは3〜50文字で指定してください" };
}
if (!/^[a-z0-9-]+$/.test(slug)) {
return {
valid: false,
message: "slugは英小文字、数字、ハイフンのみ使用できます",
};
}
if (slug.startsWith("-") || slug.endsWith("-")) {
return {
valid: false,
message: "slugの先頭・末尾にハイフンは使用できません",
};
}
return { valid: true };
},
} as const;
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import "server-only";

import type { TenantRole } from "@/server/contexts/auth/domain/models/tenant-role";

/**
* ユーザーとテナントの関連(メンバーシップ)
*/
export interface UserTenantMembership {
id: bigint;
userId: string;
tenantId: bigint;
role: TenantRole;
createdAt: Date;
updatedAt: Date;
}

/**
* テナントメンバーシップ情報(テナント詳細を含む)
*/
export interface TenantMembershipWithTenant {
membership: UserTenantMembership;
tenantSlug: string;
tenantName: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import "server-only";

import type { Tenant } from "@/server/contexts/auth/domain/models/tenant";

/**
* テナント作成時の入力
*/
export interface CreateTenantInput {
name: string;
slug: string;
description?: string;
}

/**
* テナントリポジトリインターフェース
*/
export interface TenantRepository {
/**
* IDでテナントを取得
*/
findById(id: bigint): Promise<Tenant | null>;

/**
* slugでテナントを取得
*/
findBySlug(slug: string): Promise<Tenant | null>;

/**
* テナントを作成
*/
create(input: CreateTenantInput): Promise<Tenant>;

/**
* テナントを更新
*/
update(id: bigint, input: Partial<CreateTenantInput>): Promise<Tenant>;
}
Loading
Loading