diff --git a/backend/docs/development.md b/backend/docs/development.md index 3a5b775..3f1226d 100644 --- a/backend/docs/development.md +++ b/backend/docs/development.md @@ -1,13 +1,30 @@ -## DBのマイグレーションを作成する -開発時にDrizzleのスキーマの変更が生じた場合は、\ -以下のコマンドを実行してDBのマイグレーションを作成・適用してください。 +## 開発時: DBをリセットする +現在あるDBを削除し、DBをマイグレーションが適用された状態まで進めます。 ``` -pnpm migration:generate --name [このマイグレーションの名前] -pnpm migration:apply +pnpm db:reset ``` -例: +まだマイグレーションが作成されていないスキーマの変更をDBに適用します。\ +マイグレーションとスキーマが同じ状態であればこの操作は必要ありません。 ``` -pnpm migration:generate --name honi -pnpm migration:apply +pnpm db:push +``` + +DBに初期データを作成します。 +``` +pnpm db:seed +``` + +## 開発時: スキーマ変更を適用する +開発時は、基本的にマイグレーションの作成を行わず、 +DBのスキーマ変更だけを行います。 +``` +pnpm db:push +``` + +## マイグレーションの作成 +DBに適用するスキーマの変更が確定したら、マイグレーションを作成します。 +``` +pnpm db:push +pnpm db:create-migration ``` diff --git a/backend/openapi/generated/openapi.yaml b/backend/openapi/generated/openapi.yaml index eda1691..896ec8d 100644 --- a/backend/openapi/generated/openapi.yaml +++ b/backend/openapi/generated/openapi.yaml @@ -132,9 +132,7 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/Api.v1.Leaf' + $ref: '#/components/schemas/Api.v1.GetTimelineResult' /api/v1/chatRoom/searchChatRooms: get: tags: @@ -328,9 +326,7 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/Api.v1.Leaf' + $ref: '#/components/schemas/Api.v1.GetTimelineResult' /api/v1/user/getUser: get: tags: @@ -540,12 +536,26 @@ components: type: string limit: type: string + Api.v1.GetTimelineResult: + type: object + required: + - leafs + properties: + leafs: + type: array + items: + $ref: '#/components/schemas/Api.v1.Leaf' + nextCursor: + type: string + prevCursor: + type: string Api.v1.Leaf: type: object required: - leafId - userId - content + - createdAt properties: leafId: type: string @@ -555,6 +565,8 @@ components: type: string content: type: string + createdAt: + type: string Api.v1.SearchChatRoomsQueryString: type: object properties: diff --git a/backend/openapi/generated/schema.d.ts b/backend/openapi/generated/schema.d.ts index c0ede42..2338fa9 100644 --- a/backend/openapi/generated/schema.d.ts +++ b/backend/openapi/generated/schema.d.ts @@ -371,11 +371,17 @@ export interface components { prevCursor?: string; limit?: string; }; + "Api.v1.GetTimelineResult": { + leafs: components["schemas"]["Api.v1.Leaf"][]; + nextCursor?: string; + prevCursor?: string; + }; "Api.v1.Leaf": { leafId: string; chatRoomId?: string; userId: string; content: string; + createdAt: string; }; "Api.v1.SearchChatRoomsQueryString": { offset?: string; @@ -590,7 +596,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Api.v1.Leaf"][]; + "application/json": components["schemas"]["Api.v1.GetTimelineResult"]; }; }; }; @@ -848,7 +854,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Api.v1.Leaf"][]; + "application/json": components["schemas"]["Api.v1.GetTimelineResult"]; }; }; }; diff --git a/backend/package.json b/backend/package.json index 458b00a..3b06ae4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,11 +9,11 @@ "clean": "rm -rf dist", "update-spec": "mkdir -p openapi/generated && cp -r ../spec/generated/* openapi/generated", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", - "prisma:migrate-create": "prisma migrate dev --create-only", - "prisma:migrate": "prisma migrate dev", - "prisma:push":"prisma db push", - "prisma:generate": "prisma generate", - "prisma:generate-sql": "prisma generate --sql", + "db:push": "prisma db push", + "db:reset": "prisma migrate reset --skip-seed", + "db:seed": "prisma db seed", + "db:create-migration": "prisma migrate dev --create-only", + "db:generate": "prisma generate && prisma generate --sql", "start": "node --enable-source-maps dist/main", "start:dev": "node --enable-source-maps --watch dist/main", "start:prod": "node dist/main", @@ -22,6 +22,9 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage" }, + "prisma": { + "seed": "ts-node --transpile-only prisma/seed.ts" + }, "dependencies": { "@prisma/client": "^5.19.0", "express": "^4.17.0", @@ -30,6 +33,7 @@ "passport-http-bearer": "^1.0.1", "reflect-metadata": "^0.2.0", "threads": "^1.7.0", + "ts-node": "^10.9.2", "zod": "^3.23.8" }, "devDependencies": { diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index 6ba96fd..1cdacf9 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: threads: specifier: ^1.7.0 version: 1.7.0 + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@22.5.4)(typescript@5.5.3) zod: specifier: ^3.23.8 version: 3.23.8 @@ -2355,7 +2358,6 @@ snapshots: '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 - optional: true '@eslint-community/eslint-utils@4.4.0(eslint@8.57.0)': dependencies: @@ -2589,7 +2591,6 @@ snapshots: dependencies: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.4.15 - optional: true '@nodelib/fs.scandir@2.1.5': dependencies: @@ -2638,17 +2639,13 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 - '@tsconfig/node10@1.0.11': - optional: true + '@tsconfig/node10@1.0.11': {} - '@tsconfig/node12@1.0.11': - optional: true + '@tsconfig/node12@1.0.11': {} - '@tsconfig/node14@1.0.3': - optional: true + '@tsconfig/node14@1.0.3': {} - '@tsconfig/node16@1.0.4': - optional: true + '@tsconfig/node16@1.0.4': {} '@types/accepts@1.3.7': dependencies: @@ -2909,7 +2906,6 @@ snapshots: acorn-walk@8.3.3: dependencies: acorn: 8.12.0 - optional: true acorn@8.12.0: {} @@ -2941,8 +2937,7 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 - arg@4.1.3: - optional: true + arg@4.1.3: {} argparse@1.0.10: dependencies: @@ -3155,8 +3150,7 @@ snapshots: - supports-color - ts-node - create-require@1.1.1: - optional: true + create-require@1.1.1: {} cross-spawn@7.0.3: dependencies: @@ -3199,8 +3193,7 @@ snapshots: diff-sequences@29.6.3: {} - diff@4.0.2: - optional: true + diff@4.0.2: {} dir-glob@3.0.1: dependencies: @@ -4478,7 +4471,6 @@ snapshots: typescript: 5.5.3 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - optional: true type-check@0.4.0: dependencies: @@ -4524,8 +4516,7 @@ snapshots: utils-merge@1.0.1: {} - v8-compile-cache-lib@3.0.1: - optional: true + v8-compile-cache-lib@3.0.1: {} v8-to-istanbul@9.2.0: dependencies: @@ -4574,8 +4565,7 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 - yn@3.1.1: - optional: true + yn@3.1.1: {} yocto-queue@0.1.0: {} diff --git a/backend/prisma/migrations/20240907002954_relations/migration.sql b/backend/prisma/migrations/20240907002954_relations/migration.sql deleted file mode 100644 index 243a6f1..0000000 --- a/backend/prisma/migrations/20240907002954_relations/migration.sql +++ /dev/null @@ -1,11 +0,0 @@ --- AddForeignKey -ALTER TABLE "password_verification" ADD CONSTRAINT "password_verification_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("user_id") ON DELETE RESTRICT ON UPDATE RESTRICT; - --- AddForeignKey -ALTER TABLE "token" ADD CONSTRAINT "token_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("user_id") ON DELETE RESTRICT ON UPDATE RESTRICT; - --- AddForeignKey -ALTER TABLE "token_scope" ADD CONSTRAINT "token_scope_token_id_fkey" FOREIGN KEY ("token_id") REFERENCES "token"("token_id") ON DELETE RESTRICT ON UPDATE RESTRICT; - --- AddForeignKey -ALTER TABLE "post" ADD CONSTRAINT "post_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("user_id") ON DELETE RESTRICT ON UPDATE RESTRICT; diff --git a/backend/prisma/migrations/0_init/migration.sql b/backend/prisma/migrations/20241019064444_init/migration.sql similarity index 65% rename from backend/prisma/migrations/0_init/migration.sql rename to backend/prisma/migrations/20241019064444_init/migration.sql index aaccba5..8b2edda 100644 --- a/backend/prisma/migrations/0_init/migration.sql +++ b/backend/prisma/migrations/20241019064444_init/migration.sql @@ -1,3 +1,14 @@ +-- CreateTable +CREATE TABLE "user" ( + "user_id" UUID NOT NULL DEFAULT gen_random_uuid(), + "name" VARCHAR(64) NOT NULL, + "display_name" VARCHAR(64) NOT NULL DEFAULT 'frost user', + "password_auth_enabled" BOOLEAN NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_pkey" PRIMARY KEY ("user_id") +); + -- CreateTable CREATE TABLE "password_verification" ( "password_verification_id" UUID NOT NULL DEFAULT gen_random_uuid(), @@ -10,16 +21,6 @@ CREATE TABLE "password_verification" ( CONSTRAINT "password_verification_pkey" PRIMARY KEY ("password_verification_id") ); --- CreateTable -CREATE TABLE "post" ( - "post_id" UUID NOT NULL DEFAULT gen_random_uuid(), - "chat_room_id" UUID, - "user_id" UUID NOT NULL, - "content" VARCHAR(256) NOT NULL, - - CONSTRAINT "post_pkey" PRIMARY KEY ("post_id") -); - -- CreateTable CREATE TABLE "token" ( "token_id" UUID NOT NULL DEFAULT gen_random_uuid(), @@ -27,6 +28,7 @@ CREATE TABLE "token" ( "user_id" UUID NOT NULL, "token" VARCHAR(32) NOT NULL, "expires" TIMESTAMP(6), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "token_pkey" PRIMARY KEY ("token_id") ); @@ -40,15 +42,14 @@ CREATE TABLE "token_scope" ( CONSTRAINT "token_scope_pkey" PRIMARY KEY ("token_scope_id") ); --- CreateTable -CREATE TABLE "user" ( - "user_id" UUID NOT NULL DEFAULT gen_random_uuid(), - "name" VARCHAR(64) NOT NULL, - "display_name" VARCHAR(64) NOT NULL DEFAULT 'frost user', - "password_auth_enabled" BOOLEAN NOT NULL, - - CONSTRAINT "user_pkey" PRIMARY KEY ("user_id") -); - -- CreateIndex CREATE UNIQUE INDEX "user_name_unique" ON "user"("name"); + +-- AddForeignKey +ALTER TABLE "password_verification" ADD CONSTRAINT "password_verification_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("user_id") ON DELETE RESTRICT ON UPDATE RESTRICT; + +-- AddForeignKey +ALTER TABLE "token" ADD CONSTRAINT "token_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("user_id") ON DELETE RESTRICT ON UPDATE RESTRICT; + +-- AddForeignKey +ALTER TABLE "token_scope" ADD CONSTRAINT "token_scope_token_id_fkey" FOREIGN KEY ("token_id") REFERENCES "token"("token_id") ON DELETE RESTRICT ON UPDATE RESTRICT; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index af8286b..afd1e52 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -22,6 +22,8 @@ model user { name String @db.VarChar(64) @unique(map: "user_name_unique") display_name String @db.VarChar(64) @default("frost user") password_auth_enabled Boolean + created_at DateTime @db.Timestamp(3) @default(now()) + row_version Int @default(1) // relations passwords password_verification[] @@ -36,6 +38,7 @@ model password_verification { salt String @db.VarChar(32) iteration Int hash String @db.VarChar(128) + row_version Int @default(1) // relations user user @relation(fields: [user_id], references: [user_id], onDelete: Restrict, onUpdate: Restrict) @@ -47,6 +50,8 @@ model token { user_id String @db.Uuid // FK token String @db.VarChar(32) expires DateTime? @db.Timestamp(6) + created_at DateTime @db.Timestamp(3) @default(now()) + row_version Int @default(1) // relations user user @relation(fields: [user_id], references: [user_id], onDelete: Restrict, onUpdate: Restrict) @@ -57,6 +62,7 @@ model token_scope { token_scope_id String @db.Uuid @id @default(dbgenerated("gen_random_uuid()")) // PK token_id String @db.Uuid // FK scope_name String @db.VarChar(32) + row_version Int @default(1) // relations token token @relation(fields: [token_id], references: [token_id], onDelete: Restrict, onUpdate: Restrict) @@ -64,9 +70,12 @@ model token_scope { model post { post_id String @db.Uuid @id @default(dbgenerated("gen_random_uuid()")) // PK + post_kind String @db.VarChar(16) chat_room_id String? @db.Uuid // FK user_id String @db.Uuid // FK content String @db.VarChar(256) + created_at DateTime @db.Timestamp(3) @default(now()) + row_version Int @default(1) // relations user user @relation(fields: [user_id], references: [user_id], onDelete: Restrict, onUpdate: Restrict) diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts new file mode 100644 index 0000000..6f20d48 --- /dev/null +++ b/backend/prisma/seed.ts @@ -0,0 +1,58 @@ +import { PrismaClient } from '@prisma/client'; +import { Container } from 'inversify'; +import { TYPES } from '../src/container/types'; +import { AccessContext } from '../src/modules/AccessContext'; +import * as UserRepository from '../src/repositories/UserRepository'; +import * as TokenService from '../src/services/TokenService'; + +const prisma = new PrismaClient(); + +async function main() { + // setup container + const container = new Container(); + container.bind(TYPES.Container).toConstantValue(container); + container.bind(TYPES.db).toConstantValue(prisma); + + const ctx: AccessContext = { userId: '' }; + + // create root user + let rootUser = await UserRepository.get({ userName: 'root' }, ctx, container); + if (rootUser == null) { + rootUser = await UserRepository.create({ + userName: 'root', + displayName: 'root', + passwordAuthEnabled: false, + }, ctx, container); + console.log('User "root" has been created.'); + } + ctx.userId = rootUser.userId; + + // create public user + let publicUser = await UserRepository.get({ userName: 'public' }, ctx, container); + if (publicUser == null) { + publicUser = await UserRepository.create({ + userName: 'public', + displayName: 'public', + passwordAuthEnabled: false, + }, ctx, container); + + // create token for public + const scopes = ["user.auth"]; + await TokenService.create({ + userId: publicUser.userId, + tokenKind: "access_token", + scopes: scopes, + }, ctx, container); + + console.log('User "public" has been created.'); + } +} +main() + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/backend/prisma/sql/fetchHomeTimelineLatest.sql b/backend/prisma/sql/fetchHomeTimelineLatest.sql new file mode 100644 index 0000000..c66f6ef --- /dev/null +++ b/backend/prisma/sql/fetchHomeTimelineLatest.sql @@ -0,0 +1,10 @@ +-- @param {String} $1:userId +-- @param {Int} $2:limit + +SELECT p.* +FROM "post" AS p +WHERE p.post_kind = 'timeline' + AND CAST($1 AS UUID) = CAST($1 AS UUID) +ORDER BY p.created_at DESC, p.post_id DESC +LIMIT $2 +; diff --git a/backend/prisma/sql/fetchHomeTimelineNextCursor.sql b/backend/prisma/sql/fetchHomeTimelineNextCursor.sql new file mode 100644 index 0000000..3d1db8a --- /dev/null +++ b/backend/prisma/sql/fetchHomeTimelineNextCursor.sql @@ -0,0 +1,19 @@ +-- @param {String} $1:userId +-- @param {String} $2:cursor +-- @param {Int} $3:limit + +SELECT p.* +FROM + "post" AS p, + (SELECT x.post_id, x.created_at FROM post AS x WHERE x.post_id = CAST($2 AS UUID)) AS cur +WHERE p.post_kind = 'timeline' + AND CAST($1 AS UUID) = CAST($1 AS UUID) + -- カーソル値よりも新しいリソースを取得 + -- 作成日時がカーソル側より後であるかで判定。作成日時が同じ場合はリーフIDがカーソル側より大きいかで判定。 + AND ( + p.created_at > cur.created_at + OR (p.created_at = cur.created_at AND p.post_id > cur.post_id) + ) +ORDER BY p.created_at ASC, p.post_id ASC +LIMIT $3 +; diff --git a/backend/prisma/sql/fetchHomeTimelinePrevCursor.sql b/backend/prisma/sql/fetchHomeTimelinePrevCursor.sql new file mode 100644 index 0000000..509f602 --- /dev/null +++ b/backend/prisma/sql/fetchHomeTimelinePrevCursor.sql @@ -0,0 +1,19 @@ +-- @param {String} $1:userId +-- @param {String} $2:cursor +-- @param {Int} $3:limit + +SELECT p.* +FROM + "post" AS p, + (SELECT x.post_id, x.created_at FROM post AS x WHERE x.post_id = CAST($2 AS UUID)) AS cur +WHERE p.post_kind = 'timeline' + AND CAST($1 AS UUID) = CAST($1 AS UUID) + -- カーソル値よりも古いリソースを取得 + -- 作成日時がカーソル側より前であるかで判定。作成日時が同じ場合はリーフIDがカーソル側より小さいかで判定。 + AND ( + p.created_at < cur.created_at + OR (p.created_at = cur.created_at AND p.post_id < cur.post_id) + ) +ORDER BY p.created_at DESC, p.post_id DESC +LIMIT $3 +; diff --git a/backend/src/modules/httpRoute/ApiRouteBuilder.ts b/backend/src/modules/httpRoute/ApiRouteBuilder.ts index deefdf5..624374e 100644 --- a/backend/src/modules/httpRoute/ApiRouteBuilder.ts +++ b/backend/src/modules/httpRoute/ApiRouteBuilder.ts @@ -80,22 +80,7 @@ function createMiddlewareStack( } async function asyncHandler() { - let returnValue; - const db = container.get(TYPES.db); - try { - if (method == 'POST' || method == 'DELETE') { - // 変更操作(POST, DELETE)の場合はトランザクションを開始 - returnValue = await db.$transaction(async (tx) => { - container.rebind(TYPES.db).toConstantValue(tx); - return await handler(new ApiRouteContext(params, container, req, res, user, scopes)); - }); - } else { - // 読み出し操作の場合はそのままハンドラを呼ぶ - returnValue = await handler(new ApiRouteContext(params, container, req, res, user, scopes)); - } - } finally { - container.rebind(TYPES.db).toConstantValue(db); - } + const returnValue = await handler(new ApiRouteContext(params, container, req, res, user, scopes)); // ハンドラ内でレスポンスが設定されなければレスポンスを生成する。 if (res.statusCode == 0) { if (returnValue != null) { diff --git a/backend/src/repositories/LeafRepository.ts b/backend/src/repositories/LeafRepository.ts index c15b6f3..2f4255a 100644 --- a/backend/src/repositories/LeafRepository.ts +++ b/backend/src/repositories/LeafRepository.ts @@ -1,4 +1,5 @@ import { post } from "@prisma/client"; +import * as sql from "@prisma/client/sql"; import { Container } from "inversify"; import { TYPES } from "../container/types"; import { AccessContext } from "../modules/AccessContext"; @@ -8,14 +9,35 @@ import { LeafEntity } from "../modules/entities"; /** * 投稿を作成する */ -export async function create( - params: { chatRoomId?: string, userId: string, content: string }, +export async function createTimelineLeaf( + params: { userId: string, content: string }, ctx: AccessContext, container: Container, ): Promise { const db = container.get(TYPES.db); const row = await db.post.create({ data: { + post_kind: 'timeline', + user_id: params.userId, + content: params.content, + }, + }); + + return mapEntity(row); +} + +/** + * チャット投稿を作成する +*/ +export async function createChatLeaf( + params: { chatRoomId: string, userId: string, content: string }, + ctx: AccessContext, + container: Container, +): Promise { + const db = container.get(TYPES.db); + const row = await db.post.create({ + data: { + post_kind: 'chatroom', chat_room_id: params.chatRoomId, user_id: params.userId, content: params.content, @@ -47,6 +69,30 @@ export async function get( return mapEntity(row); } +/** + * タイムラインを取得する\ + * prevCursorとnextCursorはleafIdを指定します。 +*/ +export async function fetchHomeTimeline( + params: { kind: string, prevCursor?: string, nextCursor?: string, limit?: number }, + ctx: AccessContext, + container: Container, +): Promise { + const db = container.get(TYPES.db); + const limit = params.limit ?? 50; + if (params.nextCursor != null) { + const rows = await db.$queryRawTyped(sql.fetchHomeTimelineNextCursor(ctx.userId, params.nextCursor, limit)); + rows.reverse(); + return rows.map(x => mapEntity(x)); + } else if (params.prevCursor != null) { + const rows = await db.$queryRawTyped(sql.fetchHomeTimelinePrevCursor(ctx.userId, params.prevCursor, limit)); + return rows.map(x => mapEntity(x)); + } else { + const rows = await db.$queryRawTyped(sql.fetchHomeTimelineLatest(ctx.userId, limit)); + return rows.map(x => mapEntity(x)); + } +} + /** * 投稿を削除する * @returns 削除に成功したかどうか @@ -69,6 +115,7 @@ function mapEntity(row: post): LeafEntity { const leaf: LeafEntity = { leafId: row.post_id, userId: row.user_id, + createdAt: row.created_at.toJSON(), content: row.content, }; diff --git a/backend/src/repositories/PasswordVerificationRepository.ts b/backend/src/repositories/PasswordVerificationRepository.ts index fc06270..1357cfd 100644 --- a/backend/src/repositories/PasswordVerificationRepository.ts +++ b/backend/src/repositories/PasswordVerificationRepository.ts @@ -11,7 +11,7 @@ export async function create( params: { userId: string, algorithm: string, salt: string, iteration: number, hash: string }, ctx: AccessContext, container: Container, -): Promise { +): Promise { const db = container.get(TYPES.db); const row = await db.password_verification.create({ data: { @@ -23,7 +23,7 @@ export async function create( }, }); - return row; + return mapEntity(row); } /* @@ -33,7 +33,7 @@ export async function get( params: { userId: string }, ctx: AccessContext, container: Container, -): Promise { +): Promise { const db = container.get(TYPES.db); const row = await db.password_verification.findFirst({ where: { @@ -45,9 +45,17 @@ export async function get( return undefined; } - return row; + return mapEntity(row); } +export type PasswordVerificationEntity = { + userId: string, + algorithm: string, + salt: string, + iteration: number, + hash: string, +}; + /** * パスワード検証情報を削除する * @returns 削除に成功したかどうか @@ -66,3 +74,13 @@ export async function remove( return (result.count > 0); } + +function mapEntity(row: password_verification): PasswordVerificationEntity { + return { + userId: row.user_id, + algorithm: row.algorithm, + salt: row.salt, + iteration: row.iteration, + hash: row.hash, + }; +} diff --git a/backend/src/routes/apiVer1.ts b/backend/src/routes/apiVer1.ts index c7e6b76..c484b73 100644 --- a/backend/src/routes/apiVer1.ts +++ b/backend/src/routes/apiVer1.ts @@ -7,6 +7,7 @@ import { corsApi } from '../modules/httpRoute/cors'; import type { Endpoints } from '../modules/httpRoute/endpoints'; import * as LeafService from '../services/LeafService'; import * as UserService from '../services/UserService'; +import * as AuthService from '../services/AuthService'; const zUuid = z.string().length(36); @@ -32,7 +33,7 @@ export class ApiVer1Router { password: z.string().min(1).optional(), }) ); - const result = await UserService.signin(params, { userId: ctx.getUser().userId }, ctx.container); + const result = await AuthService.signin(params, { userId: ctx.getUser().userId }, ctx.container); return result; }, }); @@ -49,7 +50,7 @@ export class ApiVer1Router { displayName: z.string().min(1), }) ); - const result = await UserService.signup(params, { userId: ctx.getUser().userId }, ctx.container); + const result = await AuthService.signup(params, { userId: ctx.getUser().userId }, ctx.container); return result; }, }); @@ -165,7 +166,20 @@ export class ApiVer1Router { path: '/user/getHomeTimeline', scope: ['user.read', 'leaf.read'], async requestHandler(ctx): Promise { - throw new Error('not implemented'); + const params: Endpoints['/api/v1/user/getHomeTimeline']['query'] = ctx.validateParams( + z.object({ + nextCursor: z.string().length(36).optional(), + prevCursor: z.string().length(36).optional(), + limit: z.string().regex(/^[+-]?\d*\.?\d+$/, { message: 'invalid numeric string' }).optional(), + }) + ); + const params2 = { + kind: 'home', + ...params, + limit: Number(params.limit), + }; + const result = await UserService.fetchHomeTimeline(params2, { userId: ctx.getUser().userId }, ctx.container); + return result; }, }); @@ -177,7 +191,7 @@ export class ApiVer1Router { const params: Endpoints['/api/v1/user/getUser']['query'] = ctx.validateParams( z.object({ userId: zUuid.optional(), - username: z.string().min(1).optional(), + userName: z.string().min(1).optional(), }) ); const result = await UserService.getUser(params, { userId: ctx.getUser().userId }, ctx.container); diff --git a/backend/src/scripts/debug/post-debug.ts b/backend/src/scripts/debug/post-debug.ts index d336866..983f6eb 100644 --- a/backend/src/scripts/debug/post-debug.ts +++ b/backend/src/scripts/debug/post-debug.ts @@ -28,7 +28,7 @@ async function run() { ctx.userId = user.userId; console.log('create'); - const createResult = await LeafRepository.create({ + const createResult = await LeafRepository.createTimelineLeaf({ userId: ctx.userId, content: 'This is a leaf content.', }, ctx, container); diff --git a/backend/src/scripts/debug/timeline-debug.ts b/backend/src/scripts/debug/timeline-debug.ts new file mode 100644 index 0000000..bf24881 --- /dev/null +++ b/backend/src/scripts/debug/timeline-debug.ts @@ -0,0 +1,64 @@ +import { Container } from 'inversify'; +import { setupContainer } from '../../container/inversify.config'; +import { TYPES } from '../../container/types'; +import { AccessContext } from '../../modules/AccessContext'; +import { PrismaClient } from '@prisma/client'; +import { inspect } from 'util'; +import * as UserRepository from '../../repositories/UserRepository'; +import * as LeafRepository from '../../repositories/LeafRepository'; + +async function run() { + const container = new Container(); + setupContainer(container); + + const ctx: AccessContext = { userId: '' }; + + // debugユーザーを取得。無ければ作る。 + console.log('get debug user'); + let user = await UserRepository.get({ userName: 'debug' }, ctx, container); + if (user == null) { + console.log('create debug user'); + user = await UserRepository.create({ + userName: 'debug', + displayName: 'Debug', + passwordAuthEnabled: false, + }, ctx, container); + } + ctx.userId = user.userId; + + container.snapshot(); + try { + const db = container.get(TYPES.db); + await db.$transaction(async (tx) => { + container.rebind(TYPES.db).toConstantValue(tx); + // create 10 posts + for (let i = 0; i < 10; i++) { + const createResult = await LeafRepository.createTimelineLeaf({ + userId: ctx.userId, + content: `This is a post content ${i}.`, + }, ctx, container); + console.log(inspect(createResult, { depth: 10 })); + } + + // fetch posts + console.log('タイムライン取得'); + const posts = await LeafRepository.fetchHomeTimeline({ + kind: 'home', + limit: 8, + }, ctx, container); + console.log(inspect(posts, { depth: 10 })); + + console.log('finish'); + + throw new Error('rollback'); + }); + } finally { + container.restore(); + const db = container.get(TYPES.db); + await db.$disconnect(); + } +} +run() + .catch(err => { + console.error(err); + }); diff --git a/backend/src/scripts/setup.ts b/backend/src/scripts/setup.ts deleted file mode 100644 index e110857..0000000 --- a/backend/src/scripts/setup.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { PrismaClient } from '@prisma/client'; -import { Container } from 'inversify'; -import { BACKEND_URSR_ID } from '../constants/specialUserId'; -import { setupContainer } from '../container/inversify.config'; -import { TYPES } from '../container/types'; -import { AccessContext } from '../modules/AccessContext'; -import * as UserRepository from '../repositories/UserRepository'; -import * as TokenService from '../services/TokenService'; - -async function run() { - // setup container - const container = new Container(); - setupContainer(container); - - // get instance - const db = container.get(TYPES.db); - - const ctx: AccessContext = { userId: BACKEND_URSR_ID }; - - const user = await UserRepository.create({ - userName: 'Public', - displayName: 'Public', - passwordAuthEnabled: false, - }, ctx, container); - console.log("ユーザー'Public'を作成しました"); - console.log(user); - - const scopes = ["user.auth"]; - const accessToken = await TokenService.create({ - userId: user.userId, - tokenKind: "access_token", - scopes: scopes, - }, ctx, container); - console.log("ユーザー'Public'のアクセストークンを作成しました"); - console.log(accessToken); - - await db.$disconnect(); -} -run() - .catch(err => { - console.error(err); - }); diff --git a/backend/src/services/AuthService.ts b/backend/src/services/AuthService.ts new file mode 100644 index 0000000..bf0810d --- /dev/null +++ b/backend/src/services/AuthService.ts @@ -0,0 +1,116 @@ +import { Container } from "inversify"; +import { AccessContext } from "../modules/AccessContext"; +import { appError, BadRequest, ResourceNotFound } from "../modules/appErrors"; +import { AuthResultEntity, LeafEntity, UserEntity } from "../modules/entities"; +import * as UserRepository from "../repositories/UserRepository"; +import * as PasswordVerificationService from "./PasswordVerificationService"; +import * as TokenService from "./TokenService"; +import * as LeafRepository from "../repositories/LeafRepository"; + +/** + * ユーザーを登録します。 + * 登録に成功すると、そのユーザーのトークンと登録情報が返されます。 +*/ +export async function signup( + params: { userName: string, displayName: string, password?: string }, + ctx: AccessContext, + container: Container, +): Promise { + if (params.userName.length < 5) { + throw appError(new BadRequest([ + { message: 'name invalid.' }, + ])); + } + + if (params.password == null) { + throw appError({ + code: "authMethodRequired", + message: "Authentication method required.", + status: 400, + }); + } + + const user = await UserRepository.create({ + userName: params.userName, + displayName: params.displayName, + passwordAuthEnabled: true, + }, ctx, container); + + await PasswordVerificationService.create({ + userId: user.userId, + password: params.password, + }, ctx, container); + + const scopes = ["user.read", "user.write", "leaf.read", "leaf.write", "leaf.delete"]; + + const accessToken = await TokenService.create({ + userId: user.userId, + tokenKind: "access_token", + scopes: scopes, + }, ctx, container); + + const refreshToken = await TokenService.create({ + userId: user.userId, + tokenKind: "refresh_token", + scopes: scopes, + }, ctx, container); + + return { accessToken, refreshToken, user }; +} + +/** + * 指定された認証情報でユーザーを認証します。 + * 認証に成功すると、そのユーザーのトークンと登録情報が返されます。 +*/ +export async function signin( + params: { userName: string, password?: string }, + ctx: AccessContext, + container: Container, +): Promise { + if (params.userName.length < 1) { + throw appError(new BadRequest([ + { message: 'name invalid.' }, + ])); + } + + const user = await UserRepository.get({ + userName: params.userName, + }, ctx, container); + + if (user == null) { + throw appError(new ResourceNotFound("User")); + } + + if (user.passwordAuthEnabled) { + if (params.password == null || params.password.length < 1) { + throw appError(new BadRequest([ + { message: 'password invalid.' }, + ])); + } + const verification = await PasswordVerificationService.verifyPassword({ + userId: user.userId, + password: params.password, + }, ctx, container); + if (!verification) { + throw appError({ + code: "incorrectCredential", + message: "The userName and/or password is incorrect.", + status: 401, + }); + } + const scopes = ["user.read", "user.write", "leaf.read", "leaf.write", "leaf.delete"]; + const accessToken = await TokenService.create({ + userId: user.userId, + tokenKind: "access_token", + scopes: scopes, + }, ctx, container); + const refreshToken = await TokenService.create({ + userId: user.userId, + tokenKind: "refresh_token", + scopes: scopes, + }, ctx, container); + return { accessToken, refreshToken, user }; + } + + throw new Error("authentication method not exists: " + user.userId); +} diff --git a/backend/src/services/LeafService.ts b/backend/src/services/LeafService.ts index 6d0127f..3a1001f 100644 --- a/backend/src/services/LeafService.ts +++ b/backend/src/services/LeafService.ts @@ -17,7 +17,7 @@ export async function createLeaf( { message: 'content invalid.' }, ])); } - const leaf = await LeafRepository.create({ + const leaf = await LeafRepository.createTimelineLeaf({ userId: ctx.userId, content: params.content, }, ctx, container); diff --git a/backend/src/services/UserService.ts b/backend/src/services/UserService.ts index 28d95b4..1e92b7d 100644 --- a/backend/src/services/UserService.ts +++ b/backend/src/services/UserService.ts @@ -1,118 +1,9 @@ import { Container } from "inversify"; import { AccessContext } from "../modules/AccessContext"; import { appError, BadRequest, ResourceNotFound } from "../modules/appErrors"; -import { AuthResultEntity, UserEntity } from "../modules/entities"; +import { LeafEntity, UserEntity } from "../modules/entities"; import * as UserRepository from "../repositories/UserRepository"; -import * as PasswordVerificationService from "./PasswordVerificationService"; -import * as TokenService from "./TokenService"; - -/** - * ユーザーを登録します。 - * 登録に成功すると、そのユーザーのトークンと登録情報が返されます。 -*/ -export async function signup( - params: { userName: string, displayName: string, password?: string }, - ctx: AccessContext, - container: Container, -): Promise { - if (params.userName.length < 5) { - throw appError(new BadRequest([ - { message: 'name invalid.' }, - ])); - } - - if (params.password == null) { - throw appError({ - code: "authMethodRequired", - message: "Authentication method required.", - status: 400, - }); - } - - const user = await UserRepository.create({ - userName: params.userName, - displayName: params.displayName, - passwordAuthEnabled: true, - }, ctx, container); - - await PasswordVerificationService.create({ - userId: user.userId, - password: params.password, - }, ctx, container); - - const scopes = ["user.read", "user.write", "leaf.read", "leaf.write", "leaf.delete"]; - - const accessToken = await TokenService.create({ - userId: user.userId, - tokenKind: "access_token", - scopes: scopes, - }, ctx, container); - - const refreshToken = await TokenService.create({ - userId: user.userId, - tokenKind: "refresh_token", - scopes: scopes, - }, ctx, container); - - return { accessToken, refreshToken, user }; -} - -/** - * 指定された認証情報でユーザーを認証します。 - * 認証に成功すると、そのユーザーのトークンと登録情報が返されます。 -*/ -export async function signin( - params: { userName: string, password?: string }, - ctx: AccessContext, - container: Container, -): Promise { - if (params.userName.length < 1) { - throw appError(new BadRequest([ - { message: 'name invalid.' }, - ])); - } - - const user = await UserRepository.get({ - userName: params.userName, - }, ctx, container); - - if (user == null) { - throw appError(new ResourceNotFound("User")); - } - - if (user.passwordAuthEnabled) { - if (params.password == null || params.password.length < 1) { - throw appError(new BadRequest([ - { message: 'password invalid.' }, - ])); - } - const verification = await PasswordVerificationService.verifyPassword({ - userId: user.userId, - password: params.password, - }, ctx, container); - if (!verification) { - throw appError({ - code: "incorrectCredential", - message: "The username and/or password is incorrect.", - status: 401, - }); - } - const scopes = ["user.read", "user.write", "leaf.read", "leaf.write", "leaf.delete"]; - const accessToken = await TokenService.create({ - userId: user.userId, - tokenKind: "access_token", - scopes: scopes, - }, ctx, container); - const refreshToken = await TokenService.create({ - userId: user.userId, - tokenKind: "refresh_token", - scopes: scopes, - }, ctx, container); - return { accessToken, refreshToken, user }; - } - - throw new Error("authentication method not exists: " + user.userId); -} +import * as LeafRepository from "../repositories/LeafRepository"; /** * ユーザー情報を取得します。 @@ -157,3 +48,19 @@ export async function deleteUser( throw appError(new ResourceNotFound("User")); } } + +/** + * タイムライン取得 +*/ +export async function fetchHomeTimeline( + params: { kind: string, prevCursor?: string, nextCursor?: string, limit?: number }, + ctx: AccessContext, + container: Container, +): Promise<{ leafs: LeafEntity[], nextCursor?: string, prevCursor?: string }> { + const leafs = await LeafRepository.fetchHomeTimeline(params, ctx, container); + return { + leafs: leafs, + nextCursor: leafs[0]?.leafId, + prevCursor: leafs[leafs.length - 1]?.leafId, + }; +} diff --git a/spec/docs/api-reference-v1.md b/spec/docs/api-reference-v1.md index c323231..d766bf2 100644 --- a/spec/docs/api-reference-v1.md +++ b/spec/docs/api-reference-v1.md @@ -39,7 +39,7 @@ GET /api/v1/user/getUser ### query string - userId (optional) -- username (optional) +- userName (optional) diff --git a/spec/generated/openapi.yaml b/spec/generated/openapi.yaml index eda1691..896ec8d 100644 --- a/spec/generated/openapi.yaml +++ b/spec/generated/openapi.yaml @@ -132,9 +132,7 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/Api.v1.Leaf' + $ref: '#/components/schemas/Api.v1.GetTimelineResult' /api/v1/chatRoom/searchChatRooms: get: tags: @@ -328,9 +326,7 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/Api.v1.Leaf' + $ref: '#/components/schemas/Api.v1.GetTimelineResult' /api/v1/user/getUser: get: tags: @@ -540,12 +536,26 @@ components: type: string limit: type: string + Api.v1.GetTimelineResult: + type: object + required: + - leafs + properties: + leafs: + type: array + items: + $ref: '#/components/schemas/Api.v1.Leaf' + nextCursor: + type: string + prevCursor: + type: string Api.v1.Leaf: type: object required: - leafId - userId - content + - createdAt properties: leafId: type: string @@ -555,6 +565,8 @@ components: type: string content: type: string + createdAt: + type: string Api.v1.SearchChatRoomsQueryString: type: object properties: diff --git a/spec/generated/schema.d.ts b/spec/generated/schema.d.ts index c0ede42..2338fa9 100644 --- a/spec/generated/schema.d.ts +++ b/spec/generated/schema.d.ts @@ -371,11 +371,17 @@ export interface components { prevCursor?: string; limit?: string; }; + "Api.v1.GetTimelineResult": { + leafs: components["schemas"]["Api.v1.Leaf"][]; + nextCursor?: string; + prevCursor?: string; + }; "Api.v1.Leaf": { leafId: string; chatRoomId?: string; userId: string; content: string; + createdAt: string; }; "Api.v1.SearchChatRoomsQueryString": { offset?: string; @@ -590,7 +596,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Api.v1.Leaf"][]; + "application/json": components["schemas"]["Api.v1.GetTimelineResult"]; }; }; }; @@ -848,7 +854,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Api.v1.Leaf"][]; + "application/json": components["schemas"]["Api.v1.GetTimelineResult"]; }; }; }; diff --git a/spec/src/api/v1/main.tsp b/spec/src/api/v1/main.tsp index b7fb3f9..47bef59 100644 --- a/spec/src/api/v1/main.tsp +++ b/spec/src/api/v1/main.tsp @@ -49,7 +49,7 @@ interface UserApi { @route("/getHomeTimeline") @tag("Leaf") - @get GetHomeTimeline(...GetHomeTimelineQueryString): Leaf[]; + @get GetHomeTimeline(...GetHomeTimelineQueryString): GetTimelineResult; } @route("/leaf") @@ -89,5 +89,5 @@ interface ChatRoomApi { @route("/getTimeline") @tag("Leaf") - @get GetTimeline(...GetChatRoomTimelineQueryString): Leaf[]; + @get GetTimeline(...GetChatRoomTimelineQueryString): GetTimelineResult; } diff --git a/spec/src/api/v1/models.tsp b/spec/src/api/v1/models.tsp index d668d21..b205d57 100644 --- a/spec/src/api/v1/models.tsp +++ b/spec/src/api/v1/models.tsp @@ -29,6 +29,13 @@ model Leaf { chatRoomId?: string; userId: string; content: string; + createdAt: string; +} + +model GetTimelineResult { + leafs: Leaf[]; + nextCursor?: string; + prevCursor?: string; } model ChatRoom {