feat: 정식 사용자의 오픈 요청과 동의 기반 예약을 영속화한다
This commit is contained in:
@@ -18,6 +18,7 @@
|
||||
"prisma:migrate:deploy:game": "prisma migrate deploy --schema prisma/game.prisma",
|
||||
"prisma:migrate:deploy:gateway": "PRISMA_SCHEMA=prisma/gateway.prisma prisma migrate deploy --schema prisma/gateway.prisma --config prisma.gateway.config.ts",
|
||||
"prisma:migrate:status:game": "prisma migrate status --schema prisma/game.prisma",
|
||||
"verify:migration:open-requests": "node scripts/verify-open-request-migration.mjs",
|
||||
"verify:migration:account-icon": "sh scripts/verify-account-icon-migration.sh",
|
||||
"verify:migration:kakao-talk": "sh scripts/verify-kakao-talk-migration.sh",
|
||||
"verify:migration:npc-selection": "sh scripts/verify-npc-selection-token-migration.sh",
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
CREATE TABLE "gateway_open_request" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"profile_name" TEXT NOT NULL REFERENCES "gateway_profile"("profile_name") ON DELETE CASCADE,
|
||||
"requested_by" TEXT NOT NULL REFERENCES "app_user"("id") ON DELETE CASCADE,
|
||||
"status" TEXT NOT NULL DEFAULT 'PENDING',
|
||||
"install" JSONB NOT NULL,
|
||||
"announcement" JSONB NOT NULL,
|
||||
"season_key" TEXT NOT NULL,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"preopen_at" TIMESTAMP(3) NOT NULL,
|
||||
"open_at" TIMESTAMP(3) NOT NULL,
|
||||
"operation_id" TEXT UNIQUE REFERENCES "gateway_operation"("id") ON DELETE RESTRICT,
|
||||
"decided_by" TEXT,
|
||||
"decided_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "gateway_open_request_status_check" CHECK ("status" IN ('PENDING','RESERVED','EXPIRED','WITHDRAWN','REJECTED','SUPERSEDED')),
|
||||
CONSTRAINT "gateway_open_request_dates_check" CHECK ("created_at" < "expires_at" AND "expires_at" <= "preopen_at" AND "preopen_at" < "open_at"),
|
||||
CONSTRAINT "gateway_open_request_decision_check" CHECK (("status" = 'RESERVED') = ("operation_id" IS NOT NULL))
|
||||
);
|
||||
CREATE INDEX "gateway_open_request_profile_name_status_expires_at_idx" ON "gateway_open_request"("profile_name", "status", "expires_at");
|
||||
CREATE UNIQUE INDEX "gateway_open_request_one_pending_per_user" ON "gateway_open_request"("profile_name", "requested_by") WHERE "status" = 'PENDING';
|
||||
CREATE TABLE "gateway_open_endorsement" (
|
||||
"request_id" TEXT NOT NULL REFERENCES "gateway_open_request"("id") ON DELETE CASCADE,
|
||||
"user_id" TEXT NOT NULL REFERENCES "app_user"("id") ON DELETE CASCADE,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY ("request_id", "user_id")
|
||||
);
|
||||
@@ -115,6 +115,8 @@ model AppUser {
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
lastLoginAt DateTime? @map("last_login_at")
|
||||
legacyData Json @default(dbgenerated("'{}'::jsonb")) @map("legacy_data")
|
||||
openRequests GatewayOpenRequest[]
|
||||
openEndorsements GatewayOpenEndorsement[]
|
||||
icons UserIcon[]
|
||||
specialAccessGrants SpecialAccountAccessGrant[]
|
||||
webPushSubscriptions WebPushSubscription[]
|
||||
@@ -254,6 +256,7 @@ model GatewayProfile {
|
||||
meta Json @default(dbgenerated("'{}'::jsonb"))
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
openRequests GatewayOpenRequest[]
|
||||
operations GatewayOperation[]
|
||||
runtimeActions GatewayRuntimeAction[]
|
||||
|
||||
@@ -407,6 +410,7 @@ model GatewayOperation {
|
||||
bulkRelease GatewayBulkRelease? @relation(fields: [bulkReleaseId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
openRequest GatewayOpenRequest?
|
||||
logs GatewayOperationLog[]
|
||||
|
||||
@@index([status, scheduledAt, createdAt])
|
||||
@@ -513,3 +517,40 @@ model SystemSetting {
|
||||
|
||||
@@map("system")
|
||||
}
|
||||
|
||||
// Immutable proposal options; approval and operation creation commit atomically.
|
||||
model GatewayOpenRequest {
|
||||
id String @id @default(uuid())
|
||||
profileName String @map("profile_name")
|
||||
profile GatewayProfile @relation(fields: [profileName], references: [profileName], onDelete: Cascade)
|
||||
requestedBy String @map("requested_by")
|
||||
requester AppUser @relation(fields: [requestedBy], references: [id], onDelete: Cascade)
|
||||
status String @default("PENDING")
|
||||
install Json
|
||||
announcement Json
|
||||
seasonKey String @map("season_key")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
preopenAt DateTime @map("preopen_at")
|
||||
openAt DateTime @map("open_at")
|
||||
operationId String? @unique @map("operation_id")
|
||||
operation GatewayOperation? @relation(fields: [operationId], references: [id], onDelete: Restrict)
|
||||
decidedBy String? @map("decided_by")
|
||||
decidedAt DateTime? @map("decided_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
endorsements GatewayOpenEndorsement[]
|
||||
|
||||
@@index([profileName, status, expiresAt])
|
||||
@@map("gateway_open_request")
|
||||
}
|
||||
|
||||
model GatewayOpenEndorsement {
|
||||
requestId String @map("request_id")
|
||||
request GatewayOpenRequest @relation(fields: [requestId], references: [id], onDelete: Cascade)
|
||||
userId String @map("user_id")
|
||||
user AppUser @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@id([requestId, userId])
|
||||
@@map("gateway_open_endorsement")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import assert from 'node:assert/strict';
|
||||
import pg from 'pg';
|
||||
|
||||
const sourceUrl = process.env.OPEN_REQUEST_DATABASE_URL;
|
||||
if (!sourceUrl) throw new Error('OPEN_REQUEST_DATABASE_URL must point to a disposable fixture instance.');
|
||||
const target = '20261022000000_add_open_requests';
|
||||
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const prismaRoot = path.join(packageRoot, 'prisma');
|
||||
const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'open-request-migration-'));
|
||||
const schema = `open_request_upgrade_${Date.now()}`;
|
||||
const url = new URL(sourceUrl);
|
||||
url.searchParams.set('schema', schema);
|
||||
const client = new pg.Client({ connectionString: sourceUrl });
|
||||
const deploy = (config) => {
|
||||
const result = spawnSync('pnpm', ['exec', 'prisma', 'migrate', 'deploy', '--config', config], {
|
||||
cwd: packageRoot,
|
||||
env: { ...process.env, GATEWAY_DATABASE_URL: url.href },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
if (result.status !== 0)
|
||||
throw new Error(`Migration deploy failed (exit ${result.status}); datasource output suppressed.`);
|
||||
};
|
||||
try {
|
||||
await fs.copyFile(path.join(prismaRoot, 'gateway.prisma'), path.join(temp, 'gateway.prisma'));
|
||||
await fs.cp(path.join(prismaRoot, 'gateway-migrations'), path.join(temp, 'gateway-migrations'), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.rm(path.join(temp, 'gateway-migrations', target), { recursive: true });
|
||||
// Keep config inside this package so Prisma's config import resolves normally.
|
||||
const config = path.join(packageRoot, `.open-request-migration-${process.pid}.config.mjs`);
|
||||
const configText = `export default { schema: ${JSON.stringify(path.join(temp, 'gateway.prisma'))}, migrations: { path: ${JSON.stringify(path.join(temp, 'gateway-migrations'))} }, datasource: { url: process.env.GATEWAY_DATABASE_URL } };\n`;
|
||||
await fs.writeFile(config, configText);
|
||||
try {
|
||||
deploy(config);
|
||||
} finally {
|
||||
await fs.rm(config);
|
||||
}
|
||||
await client.connect();
|
||||
await client.query(`SET search_path TO "${schema}", public`);
|
||||
const userId = randomUUID(),
|
||||
profileName = 'opening-upgrade:default';
|
||||
await client.query(
|
||||
'INSERT INTO app_user(id,login_id,display_name,password_hash,password_salt,updated_at) VALUES($1,$2,$2,$3,$3,NOW())',
|
||||
[userId, 'fixture-user', 'fixture-only']
|
||||
);
|
||||
await client.query(
|
||||
'INSERT INTO gateway_profile(profile_name,profile,instance_key,scenario,api_port,status,updated_at) VALUES($1,$2,$3,$3,15995,$4,NOW())',
|
||||
[profileName, 'opening-upgrade', 'default', 'STOPPED']
|
||||
);
|
||||
deploy(path.join(packageRoot, 'prisma.gateway.config.ts'));
|
||||
const after = await client.query('SELECT COUNT(*)::int AS count FROM _prisma_migrations');
|
||||
deploy(path.join(packageRoot, 'prisma.gateway.config.ts'));
|
||||
assert.deepEqual((await client.query('SELECT COUNT(*)::int AS count FROM _prisma_migrations')).rows, after.rows);
|
||||
assert.equal((await client.query('SELECT id FROM app_user WHERE id=$1', [userId])).rowCount, 1);
|
||||
const requestId = randomUUID();
|
||||
await client.query(
|
||||
`INSERT INTO gateway_open_request(id,profile_name,requested_by,install,announcement,season_key,expires_at,preopen_at,open_at,updated_at)
|
||||
VALUES($1,$2,$3,'{}','{}','fixture',NOW()+INTERVAL '1 day',NOW()+INTERVAL '2 days',NOW()+INTERVAL '3 days',NOW())`,
|
||||
[requestId, profileName, userId]
|
||||
);
|
||||
await client.query('INSERT INTO gateway_open_endorsement(request_id,user_id) VALUES($1,$2)', [requestId, userId]);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
client.query('INSERT INTO gateway_open_endorsement(request_id,user_id) VALUES($1,$2)', [requestId, userId]),
|
||||
{ code: '23505' }
|
||||
);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
client.query(
|
||||
`INSERT INTO gateway_open_request(id,profile_name,requested_by,install,announcement,season_key,expires_at,preopen_at,open_at,updated_at)
|
||||
SELECT $1,profile_name,requested_by,install,announcement,season_key,expires_at,preopen_at,open_at,NOW() FROM gateway_open_request WHERE id=$2`,
|
||||
[randomUUID(), requestId]
|
||||
),
|
||||
{ code: '23505' }
|
||||
);
|
||||
await assert.rejects(
|
||||
() => client.query("UPDATE gateway_open_request SET status='RESERVED' WHERE id=$1", [requestId]),
|
||||
{ code: '23514' }
|
||||
);
|
||||
await assert.rejects(
|
||||
() => client.query('UPDATE gateway_open_request SET open_at=preopen_at WHERE id=$1', [requestId]),
|
||||
{ code: '23514' }
|
||||
);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
client.query('INSERT INTO gateway_open_endorsement(request_id,user_id) VALUES($1,$2)', [
|
||||
randomUUID(),
|
||||
userId,
|
||||
]),
|
||||
{ code: '23503' }
|
||||
);
|
||||
console.log(
|
||||
`Opening migration: predecessor upgrade, preserved rows, repeated deploy no-op, unique/FK/CHECK constraints passed (${schema}).`
|
||||
);
|
||||
} finally {
|
||||
await client.end();
|
||||
await fs.rm(temp, { recursive: true, force: true });
|
||||
}
|
||||
Reference in New Issue
Block a user