Skip to content

Commit 08afea1

Browse files
authored
Merge pull request #3324 from AtCoder-NoviSteps/feature/atcoder-account-model
refactor(phase4): migrate account components to src/features/account
2 parents 15965cb + 0a9af83 commit 08afea1

15 files changed

Lines changed: 211 additions & 202 deletions

File tree

compose.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ services:
1212
- NODE_ENV=development
1313
- DATABASE_URL=postgresql://db_user:db_password@db:5432/test_db?pgbouncer=true&connection_limit=10&connect_timeout=60&statement_timeout=60000 # Note: Local server cannot start if port is set to db:6543.
1414
- DIRECT_URL=postgresql://db_user:db_password@db:5432/test_db
15+
- CONFIRM_API_URL=https://prettyhappy.sakura.ne.jp/php_curl/index.php
1516
command: sleep infinity
1617
depends_on:
1718
- db

prisma/ERD.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,14 +111,21 @@ ANALYSIS ANALYSIS
111111
String id "🗝️"
112112
String username
113113
Roles role
114-
String atcoder_validation_code
115-
String atcoder_username
116-
Boolean atcoder_validation_status "❓"
117114
DateTime created_at
118115
DateTime updated_at
119116
}
120117
121118
119+
"atcoder_account" {
120+
String userId "🗝️"
121+
String handle
122+
Boolean isValidated
123+
String validationCode
124+
DateTime createdAt
125+
DateTime updatedAt
126+
}
127+
128+
122129
"session" {
123130
String id "🗝️"
124131
String user_id
@@ -268,6 +275,7 @@ ANALYSIS ANALYSIS
268275
}
269276
270277
"user" |o--|| "Roles" : "enum:role"
278+
"atcoder_account" |o--|| user : "user"
271279
"session" }o--|| user : "user"
272280
"key" }o--|| user : "user"
273281
"task" |o--|| "ContestType" : "enum:contest_type"
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
-- CreateTable: must be created before data migration and column drop
2+
CREATE TABLE "atcoder_account" (
3+
"userId" TEXT NOT NULL,
4+
"handle" TEXT NOT NULL DEFAULT '',
5+
"isValidated" BOOLEAN NOT NULL DEFAULT false,
6+
"validationCode" TEXT NOT NULL DEFAULT '',
7+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
8+
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
9+
10+
CONSTRAINT "atcoder_account_pkey" PRIMARY KEY ("userId")
11+
);
12+
13+
-- AddForeignKey
14+
ALTER TABLE "atcoder_account" ADD CONSTRAINT "atcoder_account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
15+
16+
-- MigrateData: copy AtCoder fields from user to atcoder_account (only for users with a registered handle)
17+
INSERT INTO "atcoder_account" ("userId", "handle", "isValidated", "validationCode", "createdAt", "updatedAt")
18+
SELECT
19+
"id",
20+
"atcoder_username",
21+
COALESCE("atcoder_validation_status", false),
22+
"atcoder_validation_code",
23+
NOW(),
24+
NOW()
25+
FROM "user"
26+
WHERE "atcoder_username" != '';
27+
28+
-- AlterTable: drop AtCoder columns after data has been migrated
29+
ALTER TABLE "user" DROP COLUMN "atcoder_username",
30+
DROP COLUMN "atcoder_validation_code",
31+
DROP COLUMN "atcoder_validation_status";

prisma/schema.prisma

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -40,26 +40,37 @@ datasource db {
4040
// See:
4141
// https://lucia-auth.com/database-adapters/prisma/
4242
model User {
43-
id String @id @unique
43+
id String @id @unique
4444
// here you can add custom fields for your user
4545
// e.g. name, email, username, roles, etc.
46-
username String @unique
47-
role Roles @default(USER)
48-
atcoder_validation_code String @default("")
49-
atcoder_username String @default("")
50-
atcoder_validation_status Boolean? @default(false)
51-
created_at DateTime @default(now())
52-
updated_at DateTime @updatedAt
53-
54-
auth_session Session[]
55-
key Key[]
56-
taskAnswer TaskAnswer[]
57-
workBooks WorkBook[]
58-
voteGrade VoteGrade[]
46+
username String @unique
47+
role Roles @default(USER)
48+
created_at DateTime @default(now())
49+
updated_at DateTime @updatedAt
50+
51+
auth_session Session[]
52+
key Key[]
53+
taskAnswer TaskAnswer[]
54+
workBooks WorkBook[]
55+
voteGrade VoteGrade[]
56+
atCoderAccount AtCoderAccount?
5957
6058
@@map("user")
6159
}
6260

61+
model AtCoderAccount {
62+
userId String @id
63+
handle String @default("")
64+
isValidated Boolean @default(false)
65+
validationCode String @default("")
66+
createdAt DateTime @default(now())
67+
updatedAt DateTime @updatedAt
68+
69+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
70+
71+
@@map("atcoder_account")
72+
}
73+
6374
// See:
6475
// https://www.prisma.io/docs/concepts/components/prisma-schema/data-model#defining-enums
6576
enum Roles {

src/lib/components/UserAccountDeletionForm.svelte renamed to src/features/account/components/delete/AccountDeletionForm.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import ContainerWrapper from '$lib/components/ContainerWrapper.svelte';
66
import FormWrapper from '$lib/components/FormWrapper.svelte';
77
import LabelWrapper from '$lib/components/LabelWrapper.svelte';
8-
import WarningMessageOnDeletingAccount from '$lib/components/WarningMessageOnDeletingAccount.svelte';
8+
import WarningMessageOnDeletingAccount from './WarningMessageOnDeletingAccount.svelte';
99
1010
interface Props {
1111
username: string;

src/lib/components/WarningMessageOnDeletingAccount.svelte renamed to src/features/account/components/delete/WarningMessageOnDeletingAccount.svelte

File renamed without changes.

src/lib/components/AtCoderUserValidationForm.svelte renamed to src/features/account/components/settings/AtCoderVerificationForm.svelte

File renamed without changes.
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { default as db } from '$lib/server/database';
2+
import { sha256 } from '$lib/utils/hash';
3+
4+
const EXTERNAL_API_TIMEOUT_MS = 5000;
5+
6+
/** Calls the external API to check if the validation code appears in the user's AtCoder affiliation. */
7+
async function confirmWithExternalApi(handle: string, validationCode: string): Promise<boolean> {
8+
const controller = new AbortController();
9+
const timeoutId = setTimeout(() => controller.abort(), EXTERNAL_API_TIMEOUT_MS);
10+
11+
try {
12+
const baseUrl = process.env.CONFIRM_API_URL;
13+
if (!baseUrl) {
14+
throw new Error('CONFIRM_API_URL is not set.');
15+
}
16+
const url = `${baseUrl}?user=${handle}`;
17+
const response = await fetch(url, { signal: controller.signal });
18+
19+
if (!response.ok) {
20+
throw new Error('Network response was not ok.');
21+
}
22+
23+
try {
24+
const jsonData = await response.json();
25+
return jsonData.contents?.some((item: string) => item === validationCode) ?? false;
26+
} catch {
27+
// Invalid JSON from external API — treat as unconfirmed
28+
return false;
29+
}
30+
} finally {
31+
clearTimeout(timeoutId);
32+
}
33+
}
34+
35+
/**
36+
* Generates a SHA256 validation code, stores it in AtCoderAccount, and returns the code.
37+
* Creates the AtCoderAccount record if it does not exist yet.
38+
*/
39+
export async function generate(username: string, handle: string): Promise<string> {
40+
const date = new Date().toISOString();
41+
const validationCode = await sha256(username + date);
42+
43+
const user = await db.user.findUniqueOrThrow({ where: { username } });
44+
45+
await db.atCoderAccount.upsert({
46+
where: { userId: user.id },
47+
create: { userId: user.id, handle, validationCode, isValidated: false },
48+
update: { handle, validationCode, isValidated: false },
49+
});
50+
51+
return validationCode;
52+
}
53+
54+
/**
55+
* Checks the external API and, if confirmed, marks the AtCoderAccount as validated.
56+
* @returns true if validation succeeded, false otherwise.
57+
*/
58+
export async function validate(username: string): Promise<boolean> {
59+
const user = await db.user.findUniqueOrThrow({
60+
where: { username },
61+
include: { atCoderAccount: true },
62+
});
63+
64+
if (!user.atCoderAccount) {
65+
return false;
66+
}
67+
68+
if (!user.atCoderAccount.validationCode) {
69+
return false;
70+
}
71+
72+
let confirmed: boolean;
73+
74+
try {
75+
confirmed = await confirmWithExternalApi(
76+
user.atCoderAccount.handle,
77+
user.atCoderAccount.validationCode,
78+
);
79+
} catch (error) {
80+
throw new Error(`Failed to confirm AtCoder affiliation for ${username}: ${error}`);
81+
}
82+
83+
if (!confirmed) {
84+
return false;
85+
}
86+
87+
await db.atCoderAccount.update({
88+
where: { userId: user.id },
89+
data: { validationCode: '', isValidated: true },
90+
});
91+
92+
return true;
93+
}
94+
95+
/** Deletes the AtCoderAccount record, effectively resetting the verification state. */
96+
export async function reset(username: string): Promise<void> {
97+
const user = await db.user.findUniqueOrThrow({ where: { username } });
98+
await db.atCoderAccount.deleteMany({ where: { userId: user.id } });
99+
}

src/hooks.server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ export const handle: Handle = async ({ event, resolve }) => {
2222
id: user.id,
2323
name: user.username,
2424
role: user.role,
25-
atcoder_name: user.atcoder_username,
26-
is_validated: user.atcoder_validation_status,
25+
atcoder_name: user.atCoderAccount?.handle ?? '',
26+
is_validated: user.atCoderAccount?.isValidated ?? null,
2727
};
2828
}
2929

src/lib/services/users.ts

Lines changed: 7 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,19 @@
11
import { default as db } from '$lib/server/database';
2-
import type { User } from '@prisma/client';
32

43
export async function getUser(username: string) {
5-
const user = await db.user.findUnique({
6-
where: {
7-
username: username,
8-
},
4+
return await db.user.findUnique({
5+
where: { username },
6+
include: { atCoderAccount: true },
97
});
10-
return user;
118
}
129

1310
export async function getUserById(userId: string) {
14-
const user = await db.user.findUnique({
15-
where: {
16-
id: userId,
17-
},
11+
return await db.user.findUnique({
12+
where: { id: userId },
13+
include: { atCoderAccount: true },
1814
});
19-
return user;
2015
}
2116

2217
export async function deleteUser(username: string) {
23-
const user = await db.user.delete({
24-
where: {
25-
username: username,
26-
},
27-
});
28-
return user;
29-
}
30-
31-
export async function updateValicationCode(
32-
username: string,
33-
atcoder_id: string,
34-
validationCode: string,
35-
) {
36-
try {
37-
const user: User | null = await db.user.update({
38-
where: {
39-
username: username,
40-
},
41-
42-
data: {
43-
atcoder_validation_code: validationCode,
44-
atcoder_username: atcoder_id,
45-
},
46-
});
47-
48-
return user;
49-
} catch {
50-
console.log('user update error');
51-
}
18+
return await db.user.delete({ where: { username } });
5219
}

0 commit comments

Comments
 (0)