diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json
index 9b04160..218720d 100644
--- a/apps/backend/openapi.json
+++ b/apps/backend/openapi.json
@@ -4482,7 +4482,16 @@
"nullable": true,
"format": "date-time"
},
- "createdOrganizationId": {
+ "approvalMode": {
+ "type": "string",
+ "nullable": true,
+ "enum": ["create", "attach", null]
+ },
+ "approvedOrganizationId": {
+ "type": "string",
+ "nullable": true
+ },
+ "approvedOrganizationName": {
"type": "string",
"nullable": true
},
@@ -4512,7 +4521,9 @@
"requestedBy",
"reviewedBy",
"reviewedAt",
- "createdOrganizationId",
+ "approvalMode",
+ "approvedOrganizationId",
+ "approvedOrganizationName",
"createdInvitationId",
"adminNote",
"createdAt",
@@ -4620,7 +4631,16 @@
"nullable": true,
"format": "date-time"
},
- "createdOrganizationId": {
+ "approvalMode": {
+ "type": "string",
+ "nullable": true,
+ "enum": ["create", "attach", null]
+ },
+ "approvedOrganizationId": {
+ "type": "string",
+ "nullable": true
+ },
+ "approvedOrganizationName": {
"type": "string",
"nullable": true
},
@@ -4650,7 +4670,9 @@
"requestedBy",
"reviewedBy",
"reviewedAt",
- "createdOrganizationId",
+ "approvalMode",
+ "approvedOrganizationId",
+ "approvedOrganizationName",
"createdInvitationId",
"adminNote",
"createdAt",
@@ -6636,7 +6658,16 @@
"nullable": true,
"format": "date-time"
},
- "createdOrganizationId": {
+ "approvalMode": {
+ "type": "string",
+ "nullable": true,
+ "enum": ["create", "attach", null]
+ },
+ "approvedOrganizationId": {
+ "type": "string",
+ "nullable": true
+ },
+ "approvedOrganizationName": {
"type": "string",
"nullable": true
},
@@ -6666,7 +6697,9 @@
"requestedBy",
"reviewedBy",
"reviewedAt",
- "createdOrganizationId",
+ "approvalMode",
+ "approvedOrganizationId",
+ "approvedOrganizationName",
"createdInvitationId",
"adminNote",
"createdAt",
@@ -6759,6 +6792,46 @@
"in": "path"
}
],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "oneOf": [
+ {
+ "type": "object",
+ "properties": {
+ "mode": {
+ "type": "string",
+ "enum": ["create"]
+ },
+ "adminNote": {
+ "type": "string"
+ }
+ },
+ "required": ["mode"]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "mode": {
+ "type": "string",
+ "enum": ["attach"]
+ },
+ "organizationId": {
+ "type": "string",
+ "minLength": 1
+ },
+ "adminNote": {
+ "type": "string"
+ }
+ },
+ "required": ["mode", "organizationId"]
+ }
+ ]
+ }
+ }
+ }
+ },
"responses": {
"200": {
"description": "大学追加依頼承認",
@@ -6826,7 +6899,16 @@
"nullable": true,
"format": "date-time"
},
- "createdOrganizationId": {
+ "approvalMode": {
+ "type": "string",
+ "nullable": true,
+ "enum": ["create", "attach", null]
+ },
+ "approvedOrganizationId": {
+ "type": "string",
+ "nullable": true
+ },
+ "approvedOrganizationName": {
"type": "string",
"nullable": true
},
@@ -6856,7 +6938,9 @@
"requestedBy",
"reviewedBy",
"reviewedAt",
- "createdOrganizationId",
+ "approvalMode",
+ "approvedOrganizationId",
+ "approvedOrganizationName",
"createdInvitationId",
"adminNote",
"createdAt",
@@ -6869,6 +6953,21 @@
}
}
},
+ "400": {
+ "description": "不正入力",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "nullable": true
+ }
+ }
+ }
+ }
+ }
+ },
"404": {
"description": "未検出",
"content": {
@@ -6894,8 +6993,16 @@
"type": "object",
"properties": {
"error": {
- "type": "string",
- "enum": ["Already reviewed"]
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": ["Already reviewed"]
+ },
+ {
+ "type": "string",
+ "enum": ["Pending invitation already exists"]
+ }
+ ]
}
},
"required": ["error"]
@@ -7000,7 +7107,16 @@
"nullable": true,
"format": "date-time"
},
- "createdOrganizationId": {
+ "approvalMode": {
+ "type": "string",
+ "nullable": true,
+ "enum": ["create", "attach", null]
+ },
+ "approvedOrganizationId": {
+ "type": "string",
+ "nullable": true
+ },
+ "approvedOrganizationName": {
"type": "string",
"nullable": true
},
@@ -7030,7 +7146,9 @@
"requestedBy",
"reviewedBy",
"reviewedAt",
- "createdOrganizationId",
+ "approvalMode",
+ "approvedOrganizationId",
+ "approvedOrganizationName",
"createdInvitationId",
"adminNote",
"createdAt",
diff --git a/apps/backend/src/__tests__/app.issue11.integration.test.ts b/apps/backend/src/__tests__/app.issue11.integration.test.ts
index edb4970..7136c7a 100644
--- a/apps/backend/src/__tests__/app.issue11.integration.test.ts
+++ b/apps/backend/src/__tests__/app.issue11.integration.test.ts
@@ -1066,7 +1066,8 @@ describe('issue #11 api integration', () => {
message: 'Please add us',
status: 'pending',
reviewedAt: null,
- createdOrganizationId: null,
+ approvalMode: null,
+ approvedOrganizationId: null,
createdInvitationId: null,
adminNote: null,
createdAt: '2026-03-20T00:00:00.000Z',
@@ -1102,7 +1103,9 @@ describe('issue #11 api integration', () => {
},
reviewedBy: null,
reviewedAt: null,
- createdOrganizationId: null,
+ approvalMode: null,
+ approvedOrganizationId: null,
+ approvedOrganizationName: null,
createdInvitationId: null,
adminNote: null,
createdAt: '2026-03-20T00:00:00.000Z',
@@ -1121,7 +1124,9 @@ describe('issue #11 api integration', () => {
requestedByName: 'member',
requestedByEmail: 'member@example.com',
reviewedAt: null,
- createdOrganizationId: null,
+ approvalMode: null,
+ approvedOrganizationId: null,
+ approvedOrganizationName: null,
createdInvitationId: null,
adminNote: null,
createdAt: '2026-03-20T00:00:00.000Z',
@@ -1149,7 +1154,9 @@ describe('issue #11 api integration', () => {
},
reviewedBy: null,
reviewedAt: null,
- createdOrganizationId: null,
+ approvalMode: null,
+ approvedOrganizationId: null,
+ approvedOrganizationName: null,
createdInvitationId: null,
adminNote: null,
createdAt: '2026-03-20T00:00:00.000Z',
@@ -1173,6 +1180,7 @@ describe('issue #11 api integration', () => {
status: 'pending',
},
],
+ [{ id: 'member-user', name: 'member', email: 'member@example.com' }],
[],
[],
[],
@@ -1190,7 +1198,9 @@ describe('issue #11 api integration', () => {
requestedByEmail: 'member@example.com',
reviewedByUserId: 'admin-user',
reviewedAt: '2026-03-20T00:00:00.000Z',
- createdOrganizationId: 'org-new',
+ approvalMode: 'create',
+ approvedOrganizationId: 'org-new',
+ approvedOrganizationName: 'Approve University',
createdInvitationId: 'invite-new',
adminNote: null,
createdAt: '2026-03-20T00:00:00.000Z',
@@ -1204,7 +1214,11 @@ describe('issue #11 api integration', () => {
'/api/admin/university-requests/50000000-0000-4000-8000-000000000002/approve',
{
method: 'POST',
- headers: { 'x-role': 'admin' },
+ headers: {
+ 'content-type': 'application/json',
+ 'x-role': 'admin',
+ },
+ body: JSON.stringify({ mode: 'create' }),
},
);
@@ -1217,13 +1231,15 @@ describe('issue #11 api integration', () => {
invitationLink: expect.stringMatching(
/^http:\/\/localhost:3000\/invite\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
),
+ requestedByEmail: 'member@example.com',
},
});
const approveJson = (await approveRes.json()) as {
- data: { status: string; createdOrganizationId: string };
+ data: { status: string; approvedOrganizationId: string; approvalMode: string };
};
expect(approveJson.data.status).toBe('approved');
- expect(approveJson.data.createdOrganizationId).toBe('org-new');
+ expect(approveJson.data.approvedOrganizationId).toBe('org-new');
+ expect(approveJson.data.approvalMode).toBe('create');
enqueueDb(
[
@@ -1250,7 +1266,9 @@ describe('issue #11 api integration', () => {
requestedByEmail: 'member@example.com',
reviewedByUserId: 'admin-user',
reviewedAt: '2026-03-20T00:00:00.000Z',
- createdOrganizationId: null,
+ approvalMode: null,
+ approvedOrganizationId: null,
+ approvedOrganizationName: null,
createdInvitationId: null,
adminNote: 'duplicate request',
createdAt: '2026-03-20T00:00:00.000Z',
@@ -1278,6 +1296,154 @@ describe('issue #11 api integration', () => {
);
});
+ it('approves a university request by attaching it to an existing organization', async () => {
+ const app = createApp();
+
+ enqueueDb(
+ [
+ {
+ id: '50000000-0000-4000-8000-000000000004',
+ requestedByUserId: 'member-user',
+ universityName: 'Requested University',
+ representativeEmail: 'owner@attach.example',
+ message: 'Attach this',
+ status: 'pending',
+ },
+ ],
+ [{ id: 'member-user', name: 'member', email: 'member@example.com' }],
+ [{ id: 'org-existing', name: 'Existing University' }],
+ [],
+ [],
+ [],
+ [{ total: 1 }],
+ [
+ {
+ id: '50000000-0000-4000-8000-000000000004',
+ universityName: 'Requested University',
+ representativeEmail: 'owner@attach.example',
+ message: 'Attach this',
+ status: 'approved',
+ requestedById: 'member-user',
+ requestedByName: 'member',
+ requestedByEmail: 'member@example.com',
+ reviewedByUserId: 'admin-user',
+ reviewedAt: '2026-03-20T00:00:00.000Z',
+ approvalMode: 'attach',
+ approvedOrganizationId: 'org-existing',
+ approvedOrganizationName: 'Existing University',
+ createdInvitationId: 'invite-existing',
+ adminNote: 'matched manually',
+ createdAt: '2026-03-20T00:00:00.000Z',
+ updatedAt: '2026-03-20T00:00:00.000Z',
+ },
+ ],
+ [{ id: 'admin-user', name: 'admin', email: 'admin@example.com' }],
+ );
+
+ const res = await app.request(
+ '/api/admin/university-requests/50000000-0000-4000-8000-000000000004/approve',
+ {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'x-role': 'admin',
+ },
+ body: JSON.stringify({
+ mode: 'attach',
+ organizationId: 'org-existing',
+ adminNote: 'matched manually',
+ }),
+ },
+ );
+
+ expect(res.status).toBe(200);
+ expect(mockSendEmail).toHaveBeenCalledWith({
+ to: 'owner@attach.example',
+ template: 'university-owner-invitation-link',
+ payload: {
+ universityName: 'Existing University',
+ invitationLink: expect.stringMatching(
+ /^http:\/\/localhost:3000\/invite\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
+ ),
+ requestedByEmail: 'member@example.com',
+ },
+ });
+ expect(
+ (await res.json()) as { data: { approvedOrganizationId: string; approvalMode: string } },
+ ).toEqual({
+ data: expect.objectContaining({
+ approvedOrganizationId: 'org-existing',
+ approvalMode: 'attach',
+ }),
+ });
+ });
+
+ it('returns 404 when attach approval targets a missing organization', async () => {
+ const app = createApp();
+
+ enqueueDb(
+ [
+ {
+ id: '50000000-0000-4000-8000-000000000005',
+ requestedByUserId: 'member-user',
+ universityName: 'Missing Target University',
+ representativeEmail: 'owner@missing.example',
+ message: 'Attach this',
+ status: 'pending',
+ },
+ ],
+ [],
+ );
+
+ const res = await app.request(
+ '/api/admin/university-requests/50000000-0000-4000-8000-000000000005/approve',
+ {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'x-role': 'admin',
+ },
+ body: JSON.stringify({
+ mode: 'attach',
+ organizationId: 'org-missing',
+ }),
+ },
+ );
+
+ expect(res.status).toBe(404);
+ expect(await res.json()).toEqual({ error: 'Not found' });
+ });
+
+ it('returns 409 when a university request is already reviewed', async () => {
+ const app = createApp();
+
+ enqueueDb([
+ {
+ id: '50000000-0000-4000-8000-000000000006',
+ requestedByUserId: 'member-user',
+ universityName: 'Reviewed University',
+ representativeEmail: 'owner@reviewed.example',
+ message: 'Reviewed already',
+ status: 'approved',
+ },
+ ]);
+
+ const res = await app.request(
+ '/api/admin/university-requests/50000000-0000-4000-8000-000000000006/approve',
+ {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'x-role': 'admin',
+ },
+ body: JSON.stringify({ mode: 'create' }),
+ },
+ );
+
+ expect(res.status).toBe(409);
+ expect(await res.json()).toEqual({ error: 'Already reviewed' });
+ });
+
it('creates and approves participation requests', async () => {
const app = createApp();
@@ -1357,6 +1523,7 @@ describe('issue #11 api integration', () => {
status: 'pending',
},
],
+ [{ id: 'owner-user', email: 'owner@example.com' }],
[],
[
{
@@ -1399,11 +1566,22 @@ describe('issue #11 api integration', () => {
);
expect(approveRes.status).toBe(200);
+ expect(mockSendEmail).toHaveBeenCalledWith({
+ to: 'owner@example.com',
+ template: 'participation-request-approved',
+ payload: {
+ editionName: '2026 Main Edition',
+ universityName: 'Org One',
+ teamName: 'Team Rocket',
+ },
+ });
expect(
((await approveRes.json()) as { data: { createdParticipationId: string } }).data
.createdParticipationId,
).toBe('10000000-0000-4000-8000-000000000099');
+ mockSendEmail.mockClear();
+
enqueueDb(
[
{
@@ -1416,6 +1594,7 @@ describe('issue #11 api integration', () => {
status: 'pending',
},
],
+ [{ id: 'owner-user', email: 'owner@example.com' }],
[{ id: '10000000-0000-4000-8000-000000000099' }],
);
@@ -1427,6 +1606,7 @@ describe('issue #11 api integration', () => {
},
);
expect(duplicateApproveRes.status).toBe(409);
+ expect(mockSendEmail).not.toHaveBeenCalled();
});
it('filters participation requests by selected organization', async () => {
diff --git a/apps/backend/src/db/migrations/0004_stormy_dorian_gray.sql b/apps/backend/src/db/migrations/0004_stormy_dorian_gray.sql
new file mode 100644
index 0000000..0462400
--- /dev/null
+++ b/apps/backend/src/db/migrations/0004_stormy_dorian_gray.sql
@@ -0,0 +1,8 @@
+ALTER TABLE "university_creation_request" RENAME COLUMN "created_organization_id" TO "approved_organization_id";--> statement-breakpoint
+ALTER TABLE "university_creation_request" DROP CONSTRAINT "university_creation_request_created_organization_id_organization_id_fk";
+--> statement-breakpoint
+ALTER TABLE "university_creation_request" ADD COLUMN "approval_mode" text;--> statement-breakpoint
+UPDATE "university_creation_request"
+SET "approval_mode" = 'create'
+WHERE "status" = 'approved' AND "approved_organization_id" IS NOT NULL;--> statement-breakpoint
+ALTER TABLE "university_creation_request" ADD CONSTRAINT "university_creation_request_approved_organization_id_organization_id_fk" FOREIGN KEY ("approved_organization_id") REFERENCES "public"."organization"("id") ON DELETE set null ON UPDATE no action;
diff --git a/apps/backend/src/db/migrations/meta/0004_snapshot.json b/apps/backend/src/db/migrations/meta/0004_snapshot.json
new file mode 100644
index 0000000..1f332c4
--- /dev/null
+++ b/apps/backend/src/db/migrations/meta/0004_snapshot.json
@@ -0,0 +1,1828 @@
+{
+ "id": "14ab9360-3505-45f6-b5ba-9c495028b5c3",
+ "prevId": "2f56be80-e43a-44c1-9428-0c13367e3ed5",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "account_userId_idx": {
+ "name": "account_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.comment": {
+ "name": "comment",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "participation_id": {
+ "name": "participation_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "edition_id": {
+ "name": "edition_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "author_university_name": {
+ "name": "author_university_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "author_team_name": {
+ "name": "author_team_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "comment_participation_deleted_idx": {
+ "name": "comment_participation_deleted_idx",
+ "columns": [
+ {
+ "expression": "participation_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "deleted_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "comment_edition_deleted_idx": {
+ "name": "comment_edition_deleted_idx",
+ "columns": [
+ {
+ "expression": "edition_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "deleted_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "comment_participation_id_participation_id_fk": {
+ "name": "comment_participation_id_participation_id_fk",
+ "tableFrom": "comment",
+ "tableTo": "participation",
+ "columnsFrom": ["participation_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "comment_edition_id_competition_edition_id_fk": {
+ "name": "comment_edition_id_competition_edition_id_fk",
+ "tableFrom": "comment",
+ "tableTo": "competition_edition",
+ "columnsFrom": ["edition_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "comment_author_id_user_id_fk": {
+ "name": "comment_author_id_user_id_fk",
+ "tableFrom": "comment",
+ "tableTo": "user",
+ "columnsFrom": ["author_id"],
+ "columnsTo": ["id"],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.competition_edition": {
+ "name": "competition_edition",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "series_id": {
+ "name": "series_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "year": {
+ "name": "year",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rule_documents": {
+ "name": "rule_documents",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sharing_status": {
+ "name": "sharing_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'draft'"
+ },
+ "external_links": {
+ "name": "external_links",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "competition_edition_series_year_idx": {
+ "name": "competition_edition_series_year_idx",
+ "columns": [
+ {
+ "expression": "series_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "year",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "competition_edition_series_id_competition_series_id_fk": {
+ "name": "competition_edition_series_id_competition_series_id_fk",
+ "tableFrom": "competition_edition",
+ "tableTo": "competition_series",
+ "columnsFrom": ["series_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.competition_series": {
+ "name": "competition_series",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "external_links": {
+ "name": "external_links",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.invitation": {
+ "name": "invitation",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "invited_by": {
+ "name": "invited_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "invitation_organizationId_idx": {
+ "name": "invitation_organizationId_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "invitation_email_idx": {
+ "name": "invitation_email_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "invitation_organization_id_organization_id_fk": {
+ "name": "invitation_organization_id_organization_id_fk",
+ "tableFrom": "invitation",
+ "tableTo": "organization",
+ "columnsFrom": ["organization_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "invitation_invited_by_user_id_fk": {
+ "name": "invitation_invited_by_user_id_fk",
+ "tableFrom": "invitation",
+ "tableTo": "user",
+ "columnsFrom": ["invited_by"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.member": {
+ "name": "member",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'member'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "member_org_user_unique": {
+ "name": "member_org_user_unique",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "member_organizationId_idx": {
+ "name": "member_organizationId_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "member_userId_idx": {
+ "name": "member_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "member_organization_id_organization_id_fk": {
+ "name": "member_organization_id_organization_id_fk",
+ "tableFrom": "member",
+ "tableTo": "organization",
+ "columnsFrom": ["organization_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "member_user_id_user_id_fk": {
+ "name": "member_user_id_user_id_fk",
+ "tableFrom": "member",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.organization": {
+ "name": "organization",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "logo": {
+ "name": "logo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "organization_slug_unique": {
+ "name": "organization_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": ["slug"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.participation_request": {
+ "name": "participation_request",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "edition_id": {
+ "name": "edition_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "university_id": {
+ "name": "university_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "requested_by_user_id": {
+ "name": "requested_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "team_name": {
+ "name": "team_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "reviewed_by_user_id": {
+ "name": "reviewed_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reviewed_at": {
+ "name": "reviewed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_participation_id": {
+ "name": "created_participation_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "admin_note": {
+ "name": "admin_note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "participation_request_edition_university_idx": {
+ "name": "participation_request_edition_university_idx",
+ "columns": [
+ {
+ "expression": "edition_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "university_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "participation_request_requested_by_idx": {
+ "name": "participation_request_requested_by_idx",
+ "columns": [
+ {
+ "expression": "requested_by_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "participation_request_status_created_at_idx": {
+ "name": "participation_request_status_created_at_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "participation_request_edition_id_competition_edition_id_fk": {
+ "name": "participation_request_edition_id_competition_edition_id_fk",
+ "tableFrom": "participation_request",
+ "tableTo": "competition_edition",
+ "columnsFrom": ["edition_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "participation_request_university_id_organization_id_fk": {
+ "name": "participation_request_university_id_organization_id_fk",
+ "tableFrom": "participation_request",
+ "tableTo": "organization",
+ "columnsFrom": ["university_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "participation_request_requested_by_user_id_user_id_fk": {
+ "name": "participation_request_requested_by_user_id_user_id_fk",
+ "tableFrom": "participation_request",
+ "tableTo": "user",
+ "columnsFrom": ["requested_by_user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ },
+ "participation_request_reviewed_by_user_id_user_id_fk": {
+ "name": "participation_request_reviewed_by_user_id_user_id_fk",
+ "tableFrom": "participation_request",
+ "tableTo": "user",
+ "columnsFrom": ["reviewed_by_user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ },
+ "participation_request_created_participation_id_participation_id_fk": {
+ "name": "participation_request_created_participation_id_participation_id_fk",
+ "tableFrom": "participation_request",
+ "tableTo": "participation",
+ "columnsFrom": ["created_participation_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.participation": {
+ "name": "participation",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "edition_id": {
+ "name": "edition_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "university_id": {
+ "name": "university_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "team_name": {
+ "name": "team_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "participation_edition_university_idx": {
+ "name": "participation_edition_university_idx",
+ "columns": [
+ {
+ "expression": "edition_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "university_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "participation_unique": {
+ "name": "participation_unique",
+ "columns": [
+ {
+ "expression": "edition_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "university_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "team_name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "participation_edition_id_competition_edition_id_fk": {
+ "name": "participation_edition_id_competition_edition_id_fk",
+ "tableFrom": "participation",
+ "tableTo": "competition_edition",
+ "columnsFrom": ["edition_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "participation_university_id_organization_id_fk": {
+ "name": "participation_university_id_organization_id_fk",
+ "tableFrom": "participation",
+ "tableTo": "organization",
+ "columnsFrom": ["university_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "active_organization_id": {
+ "name": "active_organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "session_userId_idx": {
+ "name": "session_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": ["token"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.submission_history": {
+ "name": "submission_history",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "submission_id": {
+ "name": "submission_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "submitted_by": {
+ "name": "submitted_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "file_s3_key": {
+ "name": "file_s3_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "file_name": {
+ "name": "file_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "file_size_bytes": {
+ "name": "file_size_bytes",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "file_mime_type": {
+ "name": "file_mime_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "submission_history_submission_version_idx": {
+ "name": "submission_history_submission_version_idx",
+ "columns": [
+ {
+ "expression": "submission_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "version",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "submission_history_submission_id_submission_id_fk": {
+ "name": "submission_history_submission_id_submission_id_fk",
+ "tableFrom": "submission_history",
+ "tableTo": "submission",
+ "columnsFrom": ["submission_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "submission_history_submitted_by_user_id_fk": {
+ "name": "submission_history_submitted_by_user_id_fk",
+ "tableFrom": "submission_history",
+ "tableTo": "user",
+ "columnsFrom": ["submitted_by"],
+ "columnsTo": ["id"],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.submission_template": {
+ "name": "submission_template",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "edition_id": {
+ "name": "edition_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "accept_type": {
+ "name": "accept_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "allowed_extensions": {
+ "name": "allowed_extensions",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "url_pattern": {
+ "name": "url_pattern",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "max_file_size_mb": {
+ "name": "max_file_size_mb",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 100
+ },
+ "is_required": {
+ "name": "is_required",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "submission_template_edition_id_competition_edition_id_fk": {
+ "name": "submission_template_edition_id_competition_edition_id_fk",
+ "tableFrom": "submission_template",
+ "tableTo": "competition_edition",
+ "columnsFrom": ["edition_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.submission": {
+ "name": "submission",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "participation_id": {
+ "name": "participation_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "submitted_by": {
+ "name": "submitted_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "file_s3_key": {
+ "name": "file_s3_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "file_name": {
+ "name": "file_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "file_size_bytes": {
+ "name": "file_size_bytes",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "file_mime_type": {
+ "name": "file_mime_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "submission_template_participation_unique": {
+ "name": "submission_template_participation_unique",
+ "columns": [
+ {
+ "expression": "template_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "participation_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "submission_participation_idx": {
+ "name": "submission_participation_idx",
+ "columns": [
+ {
+ "expression": "participation_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "submission_template_id_submission_template_id_fk": {
+ "name": "submission_template_id_submission_template_id_fk",
+ "tableFrom": "submission",
+ "tableTo": "submission_template",
+ "columnsFrom": ["template_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "submission_participation_id_participation_id_fk": {
+ "name": "submission_participation_id_participation_id_fk",
+ "tableFrom": "submission",
+ "tableTo": "participation",
+ "columnsFrom": ["participation_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "submission_submitted_by_user_id_fk": {
+ "name": "submission_submitted_by_user_id_fk",
+ "tableFrom": "submission",
+ "tableTo": "user",
+ "columnsFrom": ["submitted_by"],
+ "columnsTo": ["id"],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.university_creation_request": {
+ "name": "university_creation_request",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "requested_by_user_id": {
+ "name": "requested_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "university_name": {
+ "name": "university_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "representative_email": {
+ "name": "representative_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "reviewed_by_user_id": {
+ "name": "reviewed_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reviewed_at": {
+ "name": "reviewed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "approval_mode": {
+ "name": "approval_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "approved_organization_id": {
+ "name": "approved_organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_invitation_id": {
+ "name": "created_invitation_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "admin_note": {
+ "name": "admin_note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "university_creation_request_requested_by_idx": {
+ "name": "university_creation_request_requested_by_idx",
+ "columns": [
+ {
+ "expression": "requested_by_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "university_creation_request_status_created_at_idx": {
+ "name": "university_creation_request_status_created_at_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "university_creation_request_requested_by_user_id_user_id_fk": {
+ "name": "university_creation_request_requested_by_user_id_user_id_fk",
+ "tableFrom": "university_creation_request",
+ "tableTo": "user",
+ "columnsFrom": ["requested_by_user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ },
+ "university_creation_request_reviewed_by_user_id_user_id_fk": {
+ "name": "university_creation_request_reviewed_by_user_id_user_id_fk",
+ "tableFrom": "university_creation_request",
+ "tableTo": "user",
+ "columnsFrom": ["reviewed_by_user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ },
+ "university_creation_request_approved_organization_id_organization_id_fk": {
+ "name": "university_creation_request_approved_organization_id_organization_id_fk",
+ "tableFrom": "university_creation_request",
+ "tableTo": "organization",
+ "columnsFrom": ["approved_organization_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "university_creation_request_created_invitation_id_invitation_id_fk": {
+ "name": "university_creation_request_created_invitation_id_invitation_id_fk",
+ "tableFrom": "university_creation_request",
+ "tableTo": "invitation",
+ "columnsFrom": ["created_invitation_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_admin": {
+ "name": "is_admin",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": ["email"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ {
+ "expression": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/apps/backend/src/db/migrations/meta/_journal.json b/apps/backend/src/db/migrations/meta/_journal.json
index ac15341..c4d5328 100644
--- a/apps/backend/src/db/migrations/meta/_journal.json
+++ b/apps/backend/src/db/migrations/meta/_journal.json
@@ -29,6 +29,13 @@
"when": 1774879225746,
"tag": "0003_zippy_siren",
"breakpoints": true
+ },
+ {
+ "idx": 4,
+ "version": "7",
+ "when": 1777255419908,
+ "tag": "0004_stormy_dorian_gray",
+ "breakpoints": true
}
]
}
diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts
index 32230cf..d269ef0 100644
--- a/apps/backend/src/db/schema.ts
+++ b/apps/backend/src/db/schema.ts
@@ -327,7 +327,8 @@ export const universityCreationRequests = pgTable(
onDelete: 'restrict',
}),
reviewedAt: timestamp('reviewed_at', { withTimezone: true }),
- createdOrganizationId: text('created_organization_id').references(() => organizations.id, {
+ approvalMode: text('approval_mode').$type<'create' | 'attach'>(),
+ approvedOrganizationId: text('approved_organization_id').references(() => organizations.id, {
onDelete: 'set null',
}),
createdInvitationId: text('created_invitation_id').references(() => invitations.id, {
diff --git a/apps/backend/src/routes/admin/requests.ts b/apps/backend/src/routes/admin/requests.ts
index 9ec57e4..bbe403d 100644
--- a/apps/backend/src/routes/admin/requests.ts
+++ b/apps/backend/src/routes/admin/requests.ts
@@ -37,10 +37,24 @@ const reviewerSchema = z
})
.nullable();
+const approvalModeSchema = z.enum(['create', 'attach']);
+
const reviewBodySchema = z.object({
adminNote: z.string().optional(),
});
+const approveUniversityRequestBodySchema = z.discriminatedUnion('mode', [
+ z.object({
+ mode: z.literal('create'),
+ adminNote: z.string().optional(),
+ }),
+ z.object({
+ mode: z.literal('attach'),
+ organizationId: z.string().min(1),
+ adminNote: z.string().optional(),
+ }),
+]);
+
const universityRequestSchema = z.object({
id: z.string().uuid(),
universityName: z.string(),
@@ -50,7 +64,9 @@ const universityRequestSchema = z.object({
requestedBy: requesterSchema,
reviewedBy: reviewerSchema,
reviewedAt: z.date().nullable(),
- createdOrganizationId: z.string().nullable(),
+ approvalMode: approvalModeSchema.nullable(),
+ approvedOrganizationId: z.string().nullable(),
+ approvedOrganizationName: z.string().nullable(),
createdInvitationId: z.string().nullable(),
adminNote: z.string().nullable(),
createdAt: z.date(),
@@ -131,6 +147,13 @@ const approveUniversityRequestRoute = createRoute({
path: '/university-requests/{id}/approve',
request: {
params: z.object({ id: z.string().uuid() }),
+ body: {
+ content: {
+ 'application/json': {
+ schema: approveUniversityRequestBodySchema,
+ },
+ },
+ },
},
responses: {
200: {
@@ -141,6 +164,14 @@ const approveUniversityRequestRoute = createRoute({
},
},
},
+ 400: {
+ description: '不正入力',
+ content: {
+ 'application/json': {
+ schema: z.object({ error: z.any() }),
+ },
+ },
+ },
404: {
description: '未検出',
content: {
@@ -153,7 +184,12 @@ const approveUniversityRequestRoute = createRoute({
description: '処理済み',
content: {
'application/json': {
- schema: z.object({ error: z.literal('Already reviewed') }),
+ schema: z.object({
+ error: z.union([
+ z.literal('Already reviewed'),
+ z.literal('Pending invitation already exists'),
+ ]),
+ }),
},
},
},
@@ -402,7 +438,9 @@ const listUniversityRequestDetails = async ({
requestedByEmail: users.email,
reviewedByUserId: universityCreationRequests.reviewedByUserId,
reviewedAt: universityCreationRequests.reviewedAt,
- createdOrganizationId: universityCreationRequests.createdOrganizationId,
+ approvalMode: universityCreationRequests.approvalMode,
+ approvedOrganizationId: universityCreationRequests.approvedOrganizationId,
+ approvedOrganizationName: organizations.name,
createdInvitationId: universityCreationRequests.createdInvitationId,
adminNote: universityCreationRequests.adminNote,
createdAt: universityCreationRequests.createdAt,
@@ -410,6 +448,10 @@ const listUniversityRequestDetails = async ({
})
.from(universityCreationRequests)
.innerJoin(users, eq(users.id, universityCreationRequests.requestedByUserId))
+ .leftJoin(
+ organizations,
+ eq(organizations.id, universityCreationRequests.approvedOrganizationId),
+ )
.orderBy(
direction === 'asc'
? asc(universityCreationRequests.createdAt)
@@ -434,7 +476,9 @@ const listUniversityRequestDetails = async ({
},
reviewedBy: row.reviewedByUserId ? (reviewerMap.get(row.reviewedByUserId) ?? null) : null,
reviewedAt: row.reviewedAt,
- createdOrganizationId: row.createdOrganizationId,
+ approvalMode: row.approvalMode,
+ approvedOrganizationId: row.approvedOrganizationId,
+ approvedOrganizationName: row.approvedOrganizationName,
createdInvitationId: row.createdInvitationId,
adminNote: row.adminNote,
createdAt: row.createdAt,
@@ -578,6 +622,11 @@ adminRequestRoutes.openapi(listUniversityRequestsRoute, async (c) => {
adminRequestRoutes.openapi(approveUniversityRequestRoute, async (c) => {
const user = c.get('currentUser');
const requestId = c.req.param('id');
+ const body = approveUniversityRequestBodySchema.safeParse(await c.req.json());
+ if (!body.success) {
+ return c.json({ error: body.error.flatten() }, 400);
+ }
+
const existing = await db
.select()
.from(universityCreationRequests)
@@ -592,21 +641,66 @@ adminRequestRoutes.openapi(approveUniversityRequestRoute, async (c) => {
return c.json({ error: 'Already reviewed' as const }, 409);
}
- const organizationId = randomUUID();
- const slug = await getUniqueUniversitySlug(request.universityName);
+ const requesterUserRows = await db
+ .select({ id: users.id, name: users.name, email: users.email })
+ .from(users)
+ .where(eq(users.id, request.requestedByUserId))
+ .limit(1);
+
+ const requesterUser = requesterUserRows[0];
+ if (!requesterUser) {
+ return c.json({ error: 'Not found' as const }, 404);
+ }
+
+ const organizationId = body.data.mode === 'attach' ? body.data.organizationId : randomUUID();
+ let invitationUniversityName = request.universityName;
const invitationId = randomUUID();
const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7);
- await db.insert(organizations).values({
- id: organizationId,
- name: request.universityName,
- slug,
- });
+ if (body.data.mode === 'create') {
+ const slug = await getUniqueUniversitySlug(request.universityName);
+ await db.insert(organizations).values({
+ id: organizationId,
+ name: request.universityName,
+ slug,
+ });
+ } else {
+ const organizationsRows = await db
+ .select({ id: organizations.id, name: organizations.name })
+ .from(organizations)
+ .where(eq(organizations.id, organizationId))
+ .limit(1);
+
+ const approvedOrganization = organizationsRows[0];
+ if (!approvedOrganization) {
+ return c.json({ error: 'Not found' as const }, 404);
+ }
+ invitationUniversityName = approvedOrganization.name;
+
+ const pendingInvitationRows = await db
+ .select({ id: invitations.id })
+ .from(invitations)
+ .where(
+ and(
+ eq(invitations.organizationId, organizationId),
+ eq(invitations.email, requesterUser.email),
+ eq(invitations.role, 'owner'),
+ eq(invitations.status, 'pending'),
+ ),
+ )
+ .limit(1);
+
+ if (pendingInvitationRows[0]) {
+ return c.json({ error: 'Pending invitation already exists' as const }, 409);
+ }
+ }
+
+ // 招待状を発行して代表にメール送信、ただし承認は申請元が行う形式
await db.insert(invitations).values({
id: invitationId,
organizationId,
- email: request.representativeEmail,
+ email: requesterUser.email,
role: 'owner',
inviterId: user.id,
expiresAt,
@@ -616,8 +710,9 @@ adminRequestRoutes.openapi(approveUniversityRequestRoute, async (c) => {
to: request.representativeEmail,
template: 'university-owner-invitation-link',
payload: {
- universityName: request.universityName,
+ universityName: invitationUniversityName,
invitationLink: buildInvitationLink(invitationId),
+ requestedByEmail: requesterUser.email,
},
});
@@ -627,8 +722,10 @@ adminRequestRoutes.openapi(approveUniversityRequestRoute, async (c) => {
status: 'approved',
reviewedByUserId: user.id,
reviewedAt: new Date(),
- createdOrganizationId: organizationId,
+ approvalMode: body.data.mode,
+ approvedOrganizationId: organizationId,
createdInvitationId: invitationId,
+ adminNote: body.data.adminNote ?? null,
updatedAt: new Date(),
})
.where(eq(universityCreationRequests.id, requestId));
@@ -721,6 +818,17 @@ adminRequestRoutes.openapi(approveParticipationRequestRoute, async (c) => {
return c.json({ error: 'Already reviewed' as const }, 409);
}
+ const requesterUserRows = await db
+ .select({ id: users.id, email: users.email })
+ .from(users)
+ .where(eq(users.id, request.requestedByUserId))
+ .limit(1);
+
+ const requesterUser = requesterUserRows[0];
+ if (!requesterUser) {
+ return c.json({ error: 'Not found' as const }, 404);
+ }
+
const duplicateRows = await db
.select({ id: participations.id })
.from(participations)
@@ -764,6 +872,16 @@ adminRequestRoutes.openapi(approveParticipationRequestRoute, async (c) => {
return c.json({ error: 'Not found' as const }, 404);
}
+ await emailService.sendEmail({
+ to: requesterUser.email,
+ template: 'participation-request-approved',
+ payload: {
+ editionName: detail.edition.name,
+ universityName: detail.university.name,
+ teamName: detail.teamName,
+ },
+ });
+
return c.json({ data: detail }, 200);
});
diff --git a/apps/backend/src/routes/requests.ts b/apps/backend/src/routes/requests.ts
index 9342389..40a2612 100644
--- a/apps/backend/src/routes/requests.ts
+++ b/apps/backend/src/routes/requests.ts
@@ -27,6 +27,8 @@ const reviewerSchema = z
})
.nullable();
+const approvalModeSchema = z.enum(['create', 'attach']);
+
const universityRequestSchema = z.object({
id: z.string().uuid(),
universityName: z.string(),
@@ -36,7 +38,9 @@ const universityRequestSchema = z.object({
requestedBy: requesterSchema,
reviewedBy: reviewerSchema,
reviewedAt: z.date().nullable(),
- createdOrganizationId: z.string().nullable(),
+ approvalMode: approvalModeSchema.nullable(),
+ approvedOrganizationId: z.string().nullable(),
+ approvedOrganizationName: z.string().nullable(),
createdInvitationId: z.string().nullable(),
adminNote: z.string().nullable(),
createdAt: z.date(),
@@ -213,7 +217,9 @@ requestRoutes.openapi(listUniversityRequestsRoute, async (c) => {
requestedByName: users.name,
requestedByEmail: users.email,
reviewedAt: universityCreationRequests.reviewedAt,
- createdOrganizationId: universityCreationRequests.createdOrganizationId,
+ approvalMode: universityCreationRequests.approvalMode,
+ approvedOrganizationId: universityCreationRequests.approvedOrganizationId,
+ approvedOrganizationName: organizations.name,
createdInvitationId: universityCreationRequests.createdInvitationId,
adminNote: universityCreationRequests.adminNote,
createdAt: universityCreationRequests.createdAt,
@@ -221,6 +227,10 @@ requestRoutes.openapi(listUniversityRequestsRoute, async (c) => {
})
.from(universityCreationRequests)
.innerJoin(users, eq(users.id, universityCreationRequests.requestedByUserId))
+ .leftJoin(
+ organizations,
+ eq(organizations.id, universityCreationRequests.approvedOrganizationId),
+ )
.where(eq(universityCreationRequests.requestedByUserId, user.id))
.orderBy(desc(universityCreationRequests.createdAt));
@@ -239,7 +249,9 @@ requestRoutes.openapi(listUniversityRequestsRoute, async (c) => {
},
reviewedBy: null,
reviewedAt: row.reviewedAt,
- createdOrganizationId: row.createdOrganizationId,
+ approvalMode: row.approvalMode,
+ approvedOrganizationId: row.approvedOrganizationId,
+ approvedOrganizationName: row.approvedOrganizationName,
createdInvitationId: row.createdInvitationId,
adminNote: row.adminNote,
createdAt: row.createdAt,
@@ -283,7 +295,9 @@ requestRoutes.openapi(createUniversityRequestRoute, async (c) => {
},
reviewedBy: null,
reviewedAt: request.reviewedAt,
- createdOrganizationId: request.createdOrganizationId,
+ approvalMode: request.approvalMode,
+ approvedOrganizationId: request.approvedOrganizationId,
+ approvedOrganizationName: null,
createdInvitationId: request.createdInvitationId,
adminNote: request.adminNote,
createdAt: request.createdAt,
diff --git a/apps/backend/src/services/email/interface.ts b/apps/backend/src/services/email/interface.ts
index 63f9c61..cbe1090 100644
--- a/apps/backend/src/services/email/interface.ts
+++ b/apps/backend/src/services/email/interface.ts
@@ -11,6 +11,12 @@ export type EmailTemplateMap = {
'university-owner-invitation-link': {
universityName: string;
invitationLink: string;
+ requestedByEmail: string;
+ };
+ 'participation-request-approved': {
+ editionName: string;
+ universityName: string;
+ teamName?: string | null;
};
'email-verification': {
userName: string;
diff --git a/apps/backend/src/services/email/sendgrid.test.ts b/apps/backend/src/services/email/sendgrid.test.ts
index 7a5fdea..ff626ec 100644
--- a/apps/backend/src/services/email/sendgrid.test.ts
+++ b/apps/backend/src/services/email/sendgrid.test.ts
@@ -58,6 +58,7 @@ describe('SendGridEmailService', () => {
payload: {
universityName: 'Approve University',
invitationLink: 'https://app.example.test/invite/invite-1',
+ requestedByEmail: 'member@example.com',
},
});
diff --git a/apps/backend/src/services/email/smtp.test.ts b/apps/backend/src/services/email/smtp.test.ts
index 549d34d..f8e8734 100644
--- a/apps/backend/src/services/email/smtp.test.ts
+++ b/apps/backend/src/services/email/smtp.test.ts
@@ -94,6 +94,7 @@ describe('SmtpEmailService', () => {
payload: {
universityName: 'Approve University',
invitationLink: 'https://app.example.test/invite/invite-1',
+ requestedByEmail: 'member@example.com',
},
});
diff --git a/apps/backend/src/services/email/templates.test.ts b/apps/backend/src/services/email/templates.test.ts
index 52f2557..15d071c 100644
--- a/apps/backend/src/services/email/templates.test.ts
+++ b/apps/backend/src/services/email/templates.test.ts
@@ -21,15 +21,43 @@ describe('resolveEmailTemplate', () => {
const email = resolveEmailTemplate('university-owner-invitation-link', {
universityName: 'Approve University',
invitationLink: 'https://app.example.test/invite/1234',
+ requestedByEmail: 'member@example.com',
});
expect(email.subject).toBe('Approve University の代表者招待');
expect(email.html).toContain('代表者アカウントを設定するための招待リンク');
+ expect(email.html).toContain('member@example.com');
+ expect(email.html).toContain('申請元のアカウントで開いて');
expect(email.html).toContain('https://app.example.test/invite/1234');
- expect(email.text).toContain('代表者アカウントを設定するための招待リンク');
+ expect(email.text).toContain('この申請は member@example.com のアカウントから送信されています');
+ expect(email.text).toContain('このメールの受信先ではなく、申請元のアカウントで開いて');
expect(email.text).toContain('代表者設定を開く: https://app.example.test/invite/1234');
});
+ it('renders approved participation request emails with a team name', () => {
+ const email = resolveEmailTemplate('participation-request-approved', {
+ editionName: '2026 Main Edition',
+ universityName: 'Engineering Org',
+ teamName: 'Team Rocket',
+ });
+
+ expect(email.subject).toBe('2026 Main Edition の大会追加申請が承認されました');
+ expect(email.html).toContain('2026 Main Edition への参加申請が承認されました');
+ expect(email.html).toContain('Engineering Org (Team Rocket) として参加登録されています');
+ expect(email.text).toContain('Engineering Org (Team Rocket) として参加登録されています');
+ });
+
+ it('renders approved participation request emails without a team name', () => {
+ const email = resolveEmailTemplate('participation-request-approved', {
+ editionName: '2026 Main Edition',
+ universityName: 'Engineering Org',
+ teamName: null,
+ });
+
+ expect(email.html).toContain('Engineering Org として参加登録されています');
+ expect(email.text).toContain('Engineering Org として参加登録されています');
+ });
+
it('renders member invitation emails', () => {
const email = resolveEmailTemplate('organization-member-invitation-link', {
organizationName: 'Engineering Org',
diff --git a/apps/backend/src/services/email/templates.ts b/apps/backend/src/services/email/templates.ts
index 887100f..291f872 100644
--- a/apps/backend/src/services/email/templates.ts
+++ b/apps/backend/src/services/email/templates.ts
@@ -131,7 +131,8 @@ const emailTemplateDefinitions: {
heading: `${payload.universityName} の代表者招待`,
body: [
`DocShare で ${payload.universityName} の代表者アカウントを設定するための招待リンクをお送りします。`,
- '以下のリンクから代表者アカウントの設定を完了してください。',
+ `この申請は ${payload.requestedByEmail} のアカウントから送信されています。`,
+ '以下のリンクは、このメールの受信先ではなく、申請元のアカウントで開いて代表者アカウントの設定を完了してください。',
],
action: {
label: '代表者設定を開く',
@@ -139,6 +140,19 @@ const emailTemplateDefinitions: {
},
}),
},
+ 'participation-request-approved': {
+ render: (payload) =>
+ renderEmail({
+ subject: `${payload.editionName} の大会追加申請が承認されました`,
+ heading: '大会追加申請が承認されました',
+ body: [
+ `DocShare の ${payload.editionName} への参加申請が承認されました。`,
+ `${payload.universityName}${
+ payload.teamName ? ` (${payload.teamName})` : ''
+ } として参加登録されています。`,
+ ],
+ }),
+ },
'email-verification': {
render: (payload) =>
renderEmail({
diff --git a/apps/frontend/app/(admin)/admin/requests/page.tsx b/apps/frontend/app/(admin)/admin/requests/page.tsx
index 0228c0b..a2d516f 100644
--- a/apps/frontend/app/(admin)/admin/requests/page.tsx
+++ b/apps/frontend/app/(admin)/admin/requests/page.tsx
@@ -1,17 +1,31 @@
'use client';
import type { ColumnDef } from '@tanstack/react-table';
+import { useState } from 'react';
+import { type UniversityOption, UniversitySelect } from '@/components/admin/UniversitySelect';
import { ConfirmDialog } from '@/components/common/ConfirmDialog';
import { DataTable } from '@/components/common/DataTable';
import { DateTimeDisplay } from '@/components/common/DateTimeDisplay';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { Textarea } from '@/components/ui/textarea';
import {
+ type ApproveUniversityRequestInput,
type ParticipationRequest,
type UniversityRequest,
useAdminRequestsPage,
} from '@/features/requests/hooks';
+import { cn } from '@/lib/utils';
import { REQUEST_STATUS_LABELS } from '@/lib/utils/status';
function RequestStatusBadge({ status }: { status: string }) {
@@ -20,6 +34,181 @@ function RequestStatusBadge({ status }: { status: string }) {
return
+ {request.approvalMode === 'attach' ? '既存大学へ追加' : '新規作成'}:{' '} + {request.approvedOrganizationName} +
+ ) : null}{request.representativeEmail}
{request.message}
diff --git a/apps/frontend/features/requests/hooks.ts b/apps/frontend/features/requests/hooks.ts index ae727e1..c22aa57 100644 --- a/apps/frontend/features/requests/hooks.ts +++ b/apps/frontend/features/requests/hooks.ts @@ -44,6 +44,13 @@ type ParticipationRequestFormValues = { message: string; }; +export type ApproveUniversityRequestInput = { + id: string; + mode: 'create' | 'attach'; + organizationId?: string; + adminNote?: string; +}; + export function useUniversityRequestsSection() { const queryClient = useQueryClient(); @@ -200,9 +207,20 @@ export function useAdminRequestsPage() { }); const approveUniversityMutation = useMutation({ - mutationFn: async (id: string) => { + mutationFn: async ({ id, ...body }: ApproveUniversityRequestInput) => { const result = await apiClient.POST('/api/admin/university-requests/{id}/approve', { params: { path: { id } }, + body: + body.mode === 'attach' + ? { + mode: 'attach', + organizationId: body.organizationId ?? '', + adminNote: body.adminNote, + } + : { + mode: 'create', + adminNote: body.adminNote, + }, }); return throwIfError(result); }, diff --git a/apps/frontend/lib/api/schema.ts b/apps/frontend/lib/api/schema.ts index 740f8f3..5e5ae2a 100644 --- a/apps/frontend/lib/api/schema.ts +++ b/apps/frontend/lib/api/schema.ts @@ -2630,7 +2630,10 @@ export interface paths { } | null; /** Format: date-time */ reviewedAt: string | null; - createdOrganizationId: string | null; + /** @enum {string|null} */ + approvalMode: 'create' | 'attach' | null; + approvedOrganizationId: string | null; + approvedOrganizationName: string | null; createdInvitationId: string | null; adminNote: string | null; /** Format: date-time */ @@ -2692,7 +2695,10 @@ export interface paths { } | null; /** Format: date-time */ reviewedAt: string | null; - createdOrganizationId: string | null; + /** @enum {string|null} */ + approvalMode: 'create' | 'attach' | null; + approvedOrganizationId: string | null; + approvedOrganizationName: string | null; createdInvitationId: string | null; adminNote: string | null; /** Format: date-time */ @@ -3884,7 +3890,10 @@ export interface paths { } | null; /** Format: date-time */ reviewedAt: string | null; - createdOrganizationId: string | null; + /** @enum {string|null} */ + approvalMode: 'create' | 'attach' | null; + approvedOrganizationId: string | null; + approvedOrganizationName: string | null; createdInvitationId: string | null; adminNote: string | null; /** Format: date-time */ @@ -3955,7 +3964,22 @@ export interface paths { }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + 'application/json': + | { + /** @enum {string} */ + mode: 'create'; + adminNote?: string; + } + | { + /** @enum {string} */ + mode: 'attach'; + organizationId: string; + adminNote?: string; + }; + }; + }; responses: { /** @description 大学追加依頼承認 */ 200: { @@ -3987,7 +4011,10 @@ export interface paths { } | null; /** Format: date-time */ reviewedAt: string | null; - createdOrganizationId: string | null; + /** @enum {string|null} */ + approvalMode: 'create' | 'attach' | null; + approvedOrganizationId: string | null; + approvedOrganizationName: string | null; createdInvitationId: string | null; adminNote: string | null; /** Format: date-time */ @@ -3998,6 +4025,17 @@ export interface paths { }; }; }; + /** @description 不正入力 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + error?: unknown; + }; + }; + }; /** @description 未検出 */ 404: { headers: { @@ -4017,8 +4055,7 @@ export interface paths { }; content: { 'application/json': { - /** @enum {string} */ - error: 'Already reviewed'; + error: 'Already reviewed' | 'Pending invitation already exists'; }; }; }; @@ -4086,7 +4123,10 @@ export interface paths { } | null; /** Format: date-time */ reviewedAt: string | null; - createdOrganizationId: string | null; + /** @enum {string|null} */ + approvalMode: 'create' | 'attach' | null; + approvedOrganizationId: string | null; + approvedOrganizationName: string | null; createdInvitationId: string | null; adminNote: string | null; /** Format: date-time */