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_STATUS_LABELS[status] ?? status}; } +function UniversityRequestOutcome({ request }: { request: UniversityRequest }) { + if (request.status !== 'approved' || !request.approvedOrganizationName) { + return null; + } + + const modeLabel = request.approvalMode === 'attach' ? '既存大学へ追加' : '新規作成'; + + return ( +
+ {modeLabel}: {request.approvedOrganizationName} +
+ ); +} + +function ApproveUniversityRequestDialog({ + request, + isPending, + onConfirm, +}: { + request: UniversityRequest; + isPending: boolean; + onConfirm: (input: ApproveUniversityRequestInput) => void; +}) { + const [open, setOpen] = useState(false); + const [mode, setMode] = useState<'create' | 'attach'>('create'); + const [organizationId, setOrganizationId] = useState(''); + const [selectedUniversity, setSelectedUniversity] = useState(null); + const [adminNote, setAdminNote] = useState(''); + + const reset = () => { + setMode('create'); + setOrganizationId(''); + setSelectedUniversity(null); + setAdminNote(''); + }; + + const handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) { + reset(); + } + }; + + const canSubmit = mode === 'create' || organizationId.length > 0; + + return ( + + + + + 大学追加依頼を承認しますか? + + 承認方法を選んで、{request.representativeEmail} への owner 招待を作成します。 + + + +
+
+
対象申請
+
+
{request.universityName}
+
{request.representativeEmail}
+
+
+ +
+
承認方法
+
+ + + +
+
+ + {mode === 'attach' ? ( +
+ +
+ { + setOrganizationId(id); + setSelectedUniversity(university); + }} + placeholder='大学を選択...' + /> +
+ {selectedUniversity ? ( +
+ 承認先: {selectedUniversity.name} +
+ ) : ( +
既存大学の選択が必要です。
+ )} +
+ ) : null} + +
+ +