From 88302f3623e608793a2261de10b95ed4aa413028 Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Sat, 28 Mar 2026 09:29:39 +0900 Subject: [PATCH 1/5] feat: add AtCoderAccount model and migrate data from User --- .../migration.sql | 31 ++++++ prisma/schema.prisma | 39 +++++--- src/lib/services/validateApiService.ts | 95 ------------------- 3 files changed, 56 insertions(+), 109 deletions(-) create mode 100644 prisma/migrations/20260328002556_split_atcoder_account/migration.sql delete mode 100644 src/lib/services/validateApiService.ts diff --git a/prisma/migrations/20260328002556_split_atcoder_account/migration.sql b/prisma/migrations/20260328002556_split_atcoder_account/migration.sql new file mode 100644 index 000000000..4be185271 --- /dev/null +++ b/prisma/migrations/20260328002556_split_atcoder_account/migration.sql @@ -0,0 +1,31 @@ +-- CreateTable: must be created before data migration and column drop +CREATE TABLE "atcoder_account" ( + "userId" TEXT NOT NULL, + "handle" TEXT NOT NULL DEFAULT '', + "isValidated" BOOLEAN NOT NULL DEFAULT false, + "validationCode" TEXT NOT NULL DEFAULT '', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "atcoder_account_pkey" PRIMARY KEY ("userId") +); + +-- AddForeignKey +ALTER TABLE "atcoder_account" ADD CONSTRAINT "atcoder_account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- MigrateData: copy AtCoder fields from user to atcoder_account (only for users with a registered handle) +INSERT INTO "atcoder_account" ("userId", "handle", "isValidated", "validationCode", "createdAt", "updatedAt") +SELECT + "id", + "atcoder_username", + COALESCE("atcoder_validation_status", false), + "atcoder_validation_code", + NOW(), + NOW() +FROM "user" +WHERE "atcoder_username" != ''; + +-- AlterTable: drop AtCoder columns after data has been migrated +ALTER TABLE "user" DROP COLUMN "atcoder_username", +DROP COLUMN "atcoder_validation_code", +DROP COLUMN "atcoder_validation_status"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3ce3b9eb9..342fa041e 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -40,26 +40,37 @@ datasource db { // See: // https://lucia-auth.com/database-adapters/prisma/ model User { - id String @id @unique + id String @id @unique // here you can add custom fields for your user // e.g. name, email, username, roles, etc. - username String @unique - role Roles @default(USER) - atcoder_validation_code String @default("") - atcoder_username String @default("") - atcoder_validation_status Boolean? @default(false) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - - auth_session Session[] - key Key[] - taskAnswer TaskAnswer[] - workBooks WorkBook[] - voteGrade VoteGrade[] + username String @unique + role Roles @default(USER) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + auth_session Session[] + key Key[] + taskAnswer TaskAnswer[] + workBooks WorkBook[] + voteGrade VoteGrade[] + atCoderAccount AtCoderAccount? @@map("user") } +model AtCoderAccount { + userId String @id + handle String @default("") + isValidated Boolean @default(false) + validationCode String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("atcoder_account") +} + // See: // https://www.prisma.io/docs/concepts/components/prisma-schema/data-model#defining-enums enum Roles { diff --git a/src/lib/services/validateApiService.ts b/src/lib/services/validateApiService.ts deleted file mode 100644 index db953ee9b..000000000 --- a/src/lib/services/validateApiService.ts +++ /dev/null @@ -1,95 +0,0 @@ -const confirmUrl = 'https://prettyhappy.sakura.ne.jp/php_curl/index.php'; - -import { sha256 } from '$lib/utils/hash'; -import { default as db } from '$lib/server/database'; - -async function confirm(atcoder_username: string, atcoder_validation_code: string) { - try { - const url = confirmUrl + '?user=' + atcoder_username; - const response = await fetch(url); - - if (!response.ok) { - throw new Error('Network response was not ok.'); - } - - const jsonData = await response.json(); - return jsonData.contents?.some((item: string) => item === atcoder_validation_code); - } catch (error) { - // Handle error - console.error('There was a problem fetching data:', error); - throw error; - } -} - -export async function generate(username: string, atcoder_username: string) { - //ハッシュを作る - const date = new Date().toISOString(); - const validationCode = await sha256(username + date); - console.log(username + validationCode); - - try { - const user = await db.user.update({ - where: { - username: username, - }, - data: { - atcoder_username: atcoder_username, - atcoder_validation_code: validationCode, - atcoder_validation_status: false, - }, - }); - return user.atcoder_validation_code; - } catch (error) { - throw new Error(`Failed to generate token: ${error}`); - } -} - -export async function validate(username: string) { - try { - const user = await db.user.findUniqueOrThrow({ - where: { - username: username, - }, - }); - - console.log(user); - const confirmResult = await confirm(user.atcoder_username, user.atcoder_validation_code); - console.log(user, confirmResult); - - if (confirmResult) { - await db.user.update({ - where: { - username: username, - }, - data: { - //atcoder_username: atcoder_username, - atcoder_validation_code: '', - atcoder_validation_status: true, - }, - }); - return true; - } else { - return false; - } - } catch (error) { - throw new Error(`Failed to validate user ${username}: ${error}`); - } -} - -export async function reset(username: string) { - try { - await db.user.update({ - where: { - username: username, - }, - data: { - atcoder_username: '', - atcoder_validation_code: '', - atcoder_validation_status: false, - }, - }); - return true; - } catch (error) { - throw new Error(`Failed to reset validation ${username}: ${error}`); - } -} From a8beb67faf50bd47864e0bfcf73071541bb76e1f Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Sat, 28 Mar 2026 09:29:47 +0900 Subject: [PATCH 2/5] refactor: update users.ts and hooks.server.ts to use AtCoderAccount relation --- src/hooks.server.ts | 4 ++-- src/lib/services/users.ts | 47 ++++++--------------------------------- 2 files changed, 9 insertions(+), 42 deletions(-) diff --git a/src/hooks.server.ts b/src/hooks.server.ts index b65474c26..5fb9430a8 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -22,8 +22,8 @@ export const handle: Handle = async ({ event, resolve }) => { id: user.id, name: user.username, role: user.role, - atcoder_name: user.atcoder_username, - is_validated: user.atcoder_validation_status, + atcoder_name: user.atCoderAccount?.handle ?? '', + is_validated: user.atCoderAccount?.isValidated ?? null, }; } diff --git a/src/lib/services/users.ts b/src/lib/services/users.ts index c7dea4ddd..c59badddc 100644 --- a/src/lib/services/users.ts +++ b/src/lib/services/users.ts @@ -1,52 +1,19 @@ import { default as db } from '$lib/server/database'; -import type { User } from '@prisma/client'; export async function getUser(username: string) { - const user = await db.user.findUnique({ - where: { - username: username, - }, + return await db.user.findUnique({ + where: { username }, + include: { atCoderAccount: true }, }); - return user; } export async function getUserById(userId: string) { - const user = await db.user.findUnique({ - where: { - id: userId, - }, + return await db.user.findUnique({ + where: { id: userId }, + include: { atCoderAccount: true }, }); - return user; } export async function deleteUser(username: string) { - const user = await db.user.delete({ - where: { - username: username, - }, - }); - return user; -} - -export async function updateValicationCode( - username: string, - atcoder_id: string, - validationCode: string, -) { - try { - const user: User | null = await db.user.update({ - where: { - username: username, - }, - - data: { - atcoder_validation_code: validationCode, - atcoder_username: atcoder_id, - }, - }); - - return user; - } catch { - console.log('user update error'); - } + return await db.user.delete({ where: { username } }); } From 82e83627772e4a81396d74141393efd0884202da Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Sat, 28 Mar 2026 09:29:51 +0900 Subject: [PATCH 3/5] refactor: move AtCoder verification logic to src/features/account/services --- .../account/services/atcoder_verification.ts | 79 +++++++++++++++++++ src/routes/users/[username]/+page.server.ts | 2 +- src/routes/users/edit/+page.server.ts | 44 +++++------ .../lib/utils/test_cases/account_transfer.ts | 9 --- 4 files changed, 99 insertions(+), 35 deletions(-) create mode 100644 src/features/account/services/atcoder_verification.ts diff --git a/src/features/account/services/atcoder_verification.ts b/src/features/account/services/atcoder_verification.ts new file mode 100644 index 000000000..ef2f9b3bd --- /dev/null +++ b/src/features/account/services/atcoder_verification.ts @@ -0,0 +1,79 @@ +import { default as db } from '$lib/server/database'; +import { sha256 } from '$lib/utils/hash'; + +const CONFIRM_API_URL = 'https://prettyhappy.sakura.ne.jp/php_curl/index.php'; + +/** Calls the external API to check if the validation code appears in the user's AtCoder affiliation. */ +async function confirmWithExternalApi(handle: string, validationCode: string): Promise { + const url = `${CONFIRM_API_URL}?user=${handle}`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error('Network response was not ok.'); + } + + const jsonData = await response.json(); + return jsonData.contents?.some((item: string) => item === validationCode) ?? false; +} + +/** + * Generates a SHA256 validation code, stores it in AtCoderAccount, and returns the code. + * Creates the AtCoderAccount record if it does not exist yet. + */ +export async function generate(username: string, handle: string): Promise { + const date = new Date().toISOString(); + const validationCode = await sha256(username + date); + + const user = await db.user.findUniqueOrThrow({ where: { username } }); + + await db.atCoderAccount.upsert({ + where: { userId: user.id }, + create: { userId: user.id, handle, validationCode, isValidated: false }, + update: { handle, validationCode, isValidated: false }, + }); + + return validationCode; +} + +/** + * Checks the external API and, if confirmed, marks the AtCoderAccount as validated. + * @returns true if validation succeeded, false otherwise. + */ +export async function validate(username: string): Promise { + const user = await db.user.findUniqueOrThrow({ + where: { username }, + include: { atCoderAccount: true }, + }); + + if (!user.atCoderAccount) { + return false; + } + + let confirmed: boolean; + + try { + confirmed = await confirmWithExternalApi( + user.atCoderAccount.handle, + user.atCoderAccount.validationCode, + ); + } catch (error) { + throw new Error(`Failed to confirm AtCoder affiliation for ${username}: ${error}`); + } + + if (!confirmed) { + return false; + } + + await db.atCoderAccount.update({ + where: { userId: user.id }, + data: { validationCode: '', isValidated: true }, + }); + + return true; +} + +/** Deletes the AtCoderAccount record, effectively resetting the verification state. */ +export async function reset(username: string): Promise { + const user = await db.user.findUniqueOrThrow({ where: { username } }); + await db.atCoderAccount.deleteMany({ where: { userId: user.id } }); +} diff --git a/src/routes/users/[username]/+page.server.ts b/src/routes/users/[username]/+page.server.ts index 0d6075b2c..c950e7020 100644 --- a/src/routes/users/[username]/+page.server.ts +++ b/src/routes/users/[username]/+page.server.ts @@ -41,7 +41,7 @@ export async function load({ locals, params }) { return { userId: user?.id as string, username: user?.username as string, - atcoder_username: user?.atcoder_username as string, + atcoder_username: user?.atCoderAccount?.handle ?? '', role: user?.role as Roles, isLoggedIn: (session?.user.userId === user?.id) as boolean, taskResults: taskResults, diff --git a/src/routes/users/edit/+page.server.ts b/src/routes/users/edit/+page.server.ts index d68a3b86b..629cb7cff 100644 --- a/src/routes/users/edit/+page.server.ts +++ b/src/routes/users/edit/+page.server.ts @@ -2,12 +2,12 @@ import type { Roles } from '$lib/types/user'; import * as userService from '$lib/services/users'; -import * as validationService from '$lib/services/validateApiService'; +import * as verificationService from '$features/account/services/atcoder_verification'; import type { Actions } from './$types'; import { redirect } from '@sveltejs/kit'; -export async function load({ locals }) { +export async function load({ locals, url }) { const session = await locals.auth.validate(); if (!session) { redirect(302, '/login'); @@ -21,11 +21,12 @@ export async function load({ locals }) { username: user?.username as string, role: user?.role as Roles, isLoggedIn: (session?.user.userId === user?.id) as boolean, - atcoder_username: user?.atcoder_username as string, - atcoder_validationcode: user?.atcoder_validation_code as string, - is_validated: user?.atcoder_validation_status as boolean, + atcoder_username: user?.atCoderAccount?.handle ?? '', + atcoder_validationcode: user?.atCoderAccount?.validationCode ?? '', + is_validated: user?.atCoderAccount?.isValidated ?? false, message_type: '', message: '', + openAtCoderTab: url.searchParams.get('tab') === 'atcoder', }; } catch (error) { console.error('Not found username: ', session?.user.username, error); @@ -35,39 +36,35 @@ export async function load({ locals }) { export const actions: Actions = { generate: async ({ request }) => { - console.log('users->actions->generate'); const formData = await request.formData(); const username = formData.get('username')?.toString() as string; const atcoder_username = formData.get('atcoder_username')?.toString() as string; - //console.log('ここにvalidationCodeを作成してデータベースに登録するコードを書きます'); - const validationCode = await validationService.generate(username, atcoder_username); + const validationCode = await verificationService.generate(username, atcoder_username); return { success: true, - username: username, - atcoder_username: atcoder_username, + username, + atcoder_username, atcoder_validationcode: validationCode, is_tab_atcoder: true, }; }, validate: async ({ request }) => { - console.log('users->actions->validate'); const formData = await request.formData(); const username = formData.get('username')?.toString() as string; const atcoder_username = formData.get('atcoder_username')?.toString() as string; const atcoder_validationcode = formData.get('atcoder_validationcode')?.toString() as string; - //console.log('validateを呼び、AtCoderの所属欄とAPI呼び出した結果が一致しているかを確認'); - const is_validated = await validationService.validate(username); + const is_validated = await verificationService.validate(username); return { success: is_validated, user: { - username: username, - atcoder_username: atcoder_username, - atcoder_validationcode: atcoder_validationcode, + username, + atcoder_username, + atcoder_validationcode, message_type: 'green', message: 'Successfully validated.', }, @@ -75,26 +72,23 @@ export const actions: Actions = { }, reset: async ({ request }) => { - console.log('users->actions->edit'); const formData = await request.formData(); const username = formData.get('username')?.toString() as string; const atcoder_username = formData.get('atcoder_username')?.toString() as string; - //console.log('AtCoderのユーザ名とValicationCodeをリセットする。'); - const validationCode = await validationService.reset(username); + await verificationService.reset(username); return { success: true, - username: username, - atcoder_username: atcoder_username, - atcoder_validationcode: validationCode, + username, + atcoder_username, + atcoder_validationcode: '', message_type: 'green', message: 'Successfully reset.', }; }, delete: async ({ request, locals }) => { - console.log('users->actions->delete'); const formData = await request.formData(); const username = formData.get('username')?.toString() as string; const atcoder_username = formData.get('atcoder_username')?.toString() as string; @@ -104,8 +98,8 @@ export const actions: Actions = { return { success: true, - username: username, - atcoder_username: atcoder_username, + username, + atcoder_username, atcoder_validationcode: false, message_type: 'green', message: 'Successfully deleted.', diff --git a/src/test/lib/utils/test_cases/account_transfer.ts b/src/test/lib/utils/test_cases/account_transfer.ts index d5fd14c53..a5ad17b86 100644 --- a/src/test/lib/utils/test_cases/account_transfer.ts +++ b/src/test/lib/utils/test_cases/account_transfer.ts @@ -35,9 +35,6 @@ const admin: User = { id: '1', username: 'admin', role: Roles.ADMIN, - atcoder_validation_code: '', - atcoder_username: '', - atcoder_validation_status: false, created_at: SAMPLE_CREATION_TIMESTAMP, updated_at: SAMPLE_CREATION_TIMESTAMP, }; @@ -45,9 +42,6 @@ const guest: User = { id: '2', username: 'guest', role: Roles.USER, - atcoder_validation_code: '', - atcoder_username: '', - atcoder_validation_status: false, created_at: SAMPLE_CREATION_TIMESTAMP, updated_at: SAMPLE_CREATION_TIMESTAMP, }; @@ -55,9 +49,6 @@ const general: User = { id: '3', username: 'Alice', role: Roles.USER, - atcoder_validation_code: '', - atcoder_username: '', - atcoder_validation_status: false, created_at: SAMPLE_CREATION_TIMESTAMP, updated_at: SAMPLE_CREATION_TIMESTAMP, }; From b33267126a1108a004729622a9e6fc7ed48f55e3 Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Sat, 28 Mar 2026 09:33:22 +0900 Subject: [PATCH 4/5] refactor(phase4): migrate account components to src/features/account Move AtCoderUserValidationForm, UserAccountDeletionForm, and WarningMessageOnDeletingAccount from src/lib/components/ to src/features/account/components/{settings,delete}/, and update import paths in +page.svelte accordingly. Co-Authored-By: Claude Sonnet 4.6 --- .../account/components/delete/AccountDeletionForm.svelte} | 2 +- .../delete}/WarningMessageOnDeletingAccount.svelte | 0 .../components/settings/AtCoderVerificationForm.svelte} | 0 src/routes/users/edit/+page.svelte | 6 +++--- 4 files changed, 4 insertions(+), 4 deletions(-) rename src/{lib/components/UserAccountDeletionForm.svelte => features/account/components/delete/AccountDeletionForm.svelte} (94%) rename src/{lib/components => features/account/components/delete}/WarningMessageOnDeletingAccount.svelte (100%) rename src/{lib/components/AtCoderUserValidationForm.svelte => features/account/components/settings/AtCoderVerificationForm.svelte} (100%) diff --git a/src/lib/components/UserAccountDeletionForm.svelte b/src/features/account/components/delete/AccountDeletionForm.svelte similarity index 94% rename from src/lib/components/UserAccountDeletionForm.svelte rename to src/features/account/components/delete/AccountDeletionForm.svelte index 57924ac0a..c41ea0deb 100644 --- a/src/lib/components/UserAccountDeletionForm.svelte +++ b/src/features/account/components/delete/AccountDeletionForm.svelte @@ -5,7 +5,7 @@ import ContainerWrapper from '$lib/components/ContainerWrapper.svelte'; import FormWrapper from '$lib/components/FormWrapper.svelte'; import LabelWrapper from '$lib/components/LabelWrapper.svelte'; - import WarningMessageOnDeletingAccount from '$lib/components/WarningMessageOnDeletingAccount.svelte'; + import WarningMessageOnDeletingAccount from './WarningMessageOnDeletingAccount.svelte'; interface Props { username: string; diff --git a/src/lib/components/WarningMessageOnDeletingAccount.svelte b/src/features/account/components/delete/WarningMessageOnDeletingAccount.svelte similarity index 100% rename from src/lib/components/WarningMessageOnDeletingAccount.svelte rename to src/features/account/components/delete/WarningMessageOnDeletingAccount.svelte diff --git a/src/lib/components/AtCoderUserValidationForm.svelte b/src/features/account/components/settings/AtCoderVerificationForm.svelte similarity index 100% rename from src/lib/components/AtCoderUserValidationForm.svelte rename to src/features/account/components/settings/AtCoderVerificationForm.svelte diff --git a/src/routes/users/edit/+page.svelte b/src/routes/users/edit/+page.svelte index ee8dda122..2ff1b1304 100644 --- a/src/routes/users/edit/+page.svelte +++ b/src/routes/users/edit/+page.svelte @@ -1,8 +1,8 @@