diff --git a/ReadMe.md b/ReadMe.md index 0e1c351..c2b12d3 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -127,7 +127,7 @@ git push origin master --tags [7]: https://typeorm.io/ [8]: https://swagger.io/ [9]: https://github.com/anttiviljami/openapi-backend -[10]: https://github.com/settings/tokens +[10]: https://github.com/settings/tokens/new?description=KYS-service&scopes=read:packages [11]: https://www.postgresql.org/ [12]: https://azure.microsoft.com/en-us/products/storage/blobs [13]: https://www.leancloud.cn/ diff --git a/package.json b/package.json index 70ec4c9..a60f4fb 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "web-utility": "^4.4.2" }, "devDependencies": { + "@kaiyuanshe/kys-service": "1.0.0-rc.0", "@types/jsonwebtoken": "^9.0.7", "@types/koa": "^2.15.0", "@types/koa-bodyparser": "^4.3.12", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b08f70f..ecd8879 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,6 +108,9 @@ importers: specifier: ^4.4.2 version: 4.4.2(typescript@5.7.2) devDependencies: + '@kaiyuanshe/kys-service': + specifier: 1.0.0-rc.0 + version: 1.0.0-rc.0(jsdom@25.0.1)(mobx@6.13.5)(typescript@5.7.2)(web-fetch@1.4.2(@babel/plugin-transform-modules-commonjs@7.25.9(@babel/core@7.26.0))(@types/node@20.17.7)) '@types/jsonwebtoken': specifier: ^9.0.7 version: 9.0.7 @@ -330,6 +333,11 @@ packages: '@jsdevtools/ono@7.1.3': resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} + '@kaiyuanshe/kys-service@1.0.0-rc.0': + resolution: {integrity: sha512-LW1OeD5RuNj3mI61yoGzdgQEXf7KbWuHEBx0ntfmpDp/oiHxr8xG00oS5+PQ2sFMZgCSzeqOU+315dnJFsDGXg==, tarball: https://npm.pkg.github.com/download/@kaiyuanshe/kys-service/1.0.0-rc.0/e6e370c453f3a99aa961bccd5f56e84e7e0d8e2c} + peerDependencies: + web-fetch: ^1.4.0 + '@koa/cors@5.0.0': resolution: {integrity: sha512-x/iUDjcS90W69PryLDIMgFyV21YLTnG9zOpPXS7Bkt2b8AsY3zZsIpOLBkYr9fBcF3HbkKaER5hOBZLfpLgYNw==} engines: {node: '>= 14.0.0'} @@ -3251,6 +3259,17 @@ snapshots: '@jsdevtools/ono@7.1.3': {} + '@kaiyuanshe/kys-service@1.0.0-rc.0(jsdom@25.0.1)(mobx@6.13.5)(typescript@5.7.2)(web-fetch@1.4.2(@babel/plugin-transform-modules-commonjs@7.25.9(@babel/core@7.26.0))(@types/node@20.17.7))': + dependencies: + '@types/jsonwebtoken': 9.0.7 + '@types/koa': 2.15.0 + mobx-restful: 2.0.0(jsdom@25.0.1)(mobx@6.13.5)(typescript@5.7.2) + web-fetch: 1.4.2(@babel/plugin-transform-modules-commonjs@7.25.9(@babel/core@7.26.0))(@types/node@20.17.7) + transitivePeerDependencies: + - jsdom + - mobx + - typescript + '@koa/cors@5.0.0': dependencies: vary: 1.1.2 diff --git a/src/controller/CheckEvent.ts b/src/controller/CheckEvent.ts index 5e55bd4..918d727 100644 --- a/src/controller/CheckEvent.ts +++ b/src/controller/CheckEvent.ts @@ -6,22 +6,27 @@ import { ForbiddenError, Get, JsonController, + Param, Post, - QueryParams -} from 'routing-controllers'; -import { ResponseSchema } from 'routing-controllers-openapi'; + QueryParams, +} from "routing-controllers"; +import { ResponseSchema } from "routing-controllers-openapi"; import { + ActivityCheckInListChunk, + BaseFilter, CheckEvent, CheckEventChunk, CheckEventFilter, CheckEventInput, + dataSource, User, - dataSource -} from '../model'; -import { ActivityLogController } from './ActivityLog'; + UserActivityCheckInListChunk, +} from "../model"; +import { ActivityLogController } from "./ActivityLog"; +import { FindOptionsWhere } from "typeorm"; -@JsonController('/event/check') +@JsonController("/event/check") export class CheckEventController { store = dataSource.getRepository(CheckEvent); userStore = dataSource.getRepository(User); @@ -31,26 +36,26 @@ export class CheckEventController { @ResponseSchema(CheckEvent) async createOne( @CurrentUser() createdBy: User, - @Body() { user: id, ...data }: CheckEventInput + @Body() { user: id, ...data }: CheckEventInput, ) { - if (createdBy.id === id) throw new ForbiddenError('No self-checking'); + if (createdBy.id === id) throw new ForbiddenError("No self-checking"); const user = await this.userStore.findOne({ where: { id } }); - if (!user) throw new BadRequestError('Invalid user: ' + id); + if (!user) throw new BadRequestError("Invalid user: " + id); const checked = await this.store.findOne({ - where: { ...data, user: { id } } + where: { ...data, user: { id } }, }); - if (checked) throw new ForbiddenError('No duplicated check'); + if (checked) throw new ForbiddenError("No duplicated check"); const saved = await this.store.save({ ...data, createdBy, user }); await ActivityLogController.logCreate( createdBy, - 'CheckEvent', - saved.id + "CheckEvent", + saved.id, ); return saved; } @@ -58,24 +63,70 @@ export class CheckEventController { @Get() @ResponseSchema(CheckEventChunk) async getSessionList( - @QueryParams() - { + @QueryParams() { user: id, activityId, agendaId, pageSize = 10, - pageIndex = 1 - }: CheckEventFilter + pageIndex = 1, + }: CheckEventFilter, ) { const [list, count] = await this.store.findAndCount({ where: { ...(id ? { user: { id } } : {}), activityId, - agendaId + agendaId, }, - relations: ['user'], + relations: ["user"], skip: pageSize * (pageIndex - 1), - take: pageSize + take: pageSize, + }); + return { list, count }; + } + + @Get("/user/:id") + @ResponseSchema(UserActivityCheckInListChunk) + getCheckEventList( + @Param("id") id: number, + @QueryParams() filter: BaseFilter, + ) { + return this.queryList( + { user: { id } }, + filter, + ["user"], + ); + } + + @Get("/activity/:id") + @ResponseSchema(ActivityCheckInListChunk) + getActivityCheckEventList( + @Param("id") id: string, + @QueryParams() filter: BaseFilter, + ) { + return this.queryList({ activityId: id }, filter); + } + + @Get("/activity") + @ResponseSchema(ActivityCheckInListChunk) + getAgendaCheckEventList( + @Param("id") id: string, + @QueryParams() filter: BaseFilter, + ) { + return this.queryList({ agendaId: id }, filter); + } + + async queryList( + where: FindOptionsWhere<CheckEvent>, + { pageSize, pageIndex, sort }: BaseFilter, + relations?: string[], + ) { + const skip = pageSize * (pageIndex - 1); + + const [list, count] = await this.store.findAndCount({ + where, + relations, + skip, + take: pageSize, }); return { list, count }; } diff --git a/src/model/ActivityLog.ts b/src/model/ActivityLog.ts index d568e3e..4910a5d 100644 --- a/src/model/ActivityLog.ts +++ b/src/model/ActivityLog.ts @@ -1,38 +1,38 @@ -import { Type } from 'class-transformer'; +import { Type } from "class-transformer"; import { IsEnum, IsInt, IsObject, IsOptional, Min, - ValidateNested -} from 'class-validator'; -import { Column, Entity, ViewColumn, ViewEntity } from 'typeorm'; + ValidateNested, +} from "class-validator"; +import { Column, Entity, ViewColumn, ViewEntity } from "typeorm"; -import { Base, BaseFilter, InputData, ListChunk } from './Base'; -import { CheckEvent } from './CheckEvent'; -import { User, UserBase } from './User'; +import { Base, BaseFilter, InputData, ListChunk } from "./Base"; +import { CheckEvent } from "./CheckEvent"; +import { User, UserBase } from "./User"; export enum Operation { - Create = 'create', - Update = 'update', - Delete = 'delete' + Create = "create", + Update = "update", + Delete = "delete", } export const LogableTable = { User, CheckEvent }; const LogableTableEnum = Object.fromEntries( - Object.entries(LogableTable).map(([key]) => [key, key]) + Object.entries(LogableTable).map(([key]) => [key, key]), ); @Entity() export class ActivityLog extends UserBase { @IsEnum(Operation) - @Column({ type: 'simple-enum', enum: Operation }) + @Column({ type: "simple-enum", enum: Operation }) operation: Operation; @IsEnum(LogableTableEnum) - @Column({ type: 'simple-enum', enum: LogableTableEnum }) + @Column({ type: "simple-enum", enum: LogableTableEnum }) tableName: keyof typeof LogableTable; @IsInt() @@ -45,10 +45,8 @@ export class ActivityLog extends UserBase { record?: Base; } -export class ActivityLogFilter - extends BaseFilter - implements Partial<InputData<ActivityLog>> -{ +export class ActivityLogFilter extends BaseFilter + implements Partial<InputData<ActivityLog>> { @IsEnum(Operation) @IsOptional() operation?: Operation; @@ -65,13 +63,13 @@ export class ActivityLogListChunk implements ListChunk<ActivityLog> { } @ViewEntity({ - expression: connection => + expression: (connection) => connection .createQueryBuilder() - .from(ActivityLog, 'al') - .groupBy('al.createdBy') - .select('al.createdBy.id', 'userId') - .addSelect('COUNT(al.id)', 'score') + .from(ActivityLog, "al") + .groupBy("al.createdBy") + .select("al.createdBy.id", "userId") + .addSelect("COUNT(al.id)", "score"), }) export class UserRank { @IsInt() diff --git a/src/model/Base.ts b/src/model/Base.ts index e77f92b..316be71 100644 --- a/src/model/Base.ts +++ b/src/model/Base.ts @@ -1,17 +1,23 @@ -import { Type } from 'class-transformer'; +import { Type } from "class-transformer"; import { IsDateString, + IsEnum, IsInt, IsOptional, IsString, - Min -} from 'class-validator'; -import { NewData } from 'mobx-restful'; + Min, +} from "class-validator"; +import { NewData } from "mobx-restful"; import { CreateDateColumn, PrimaryGeneratedColumn, - UpdateDateColumn -} from 'typeorm'; + UpdateDateColumn, +} from "typeorm"; + +export enum Sort { + DESC = "DESC", + ASC = "ASC", +} export abstract class Base { constructor(id?: number) { @@ -52,6 +58,10 @@ export class BaseFilter { @IsString() @IsOptional() keywords?: string; + + @IsEnum(Sort) + @IsOptional() + sort?: Sort; } export interface ListChunk<T> { diff --git a/src/model/CheckEvent.ts b/src/model/CheckEvent.ts index 9782efa..220020e 100644 --- a/src/model/CheckEvent.ts +++ b/src/model/CheckEvent.ts @@ -1,16 +1,17 @@ -import { Type } from 'class-transformer'; +import { Type } from "class-transformer"; import { + IsEnum, IsInt, IsLatLong, IsOptional, IsString, Min, - ValidateNested -} from 'class-validator'; -import { Column, Entity, ManyToOne } from 'typeorm'; + ValidateNested, +} from "class-validator"; +import { Column, Entity, ManyToOne, ViewColumn, ViewEntity } from "typeorm"; -import { BaseFilter, ListChunk } from './Base'; -import { User, UserBase, UserInputData } from './User'; +import { BaseFilter, ListChunk } from "./Base"; +import { User, UserBase, UserInputData } from "./User"; @Entity() export class CheckEvent extends UserBase { @@ -63,10 +64,8 @@ export class CheckEventInput implements UserInputData<CheckEvent> { agendaTitle: string; } -export class CheckEventFilter - extends BaseFilter - implements Partial<BaseFilter & CheckEventInput> -{ +export class CheckEventFilter extends BaseFilter + implements Partial<BaseFilter & CheckEventInput> { @IsInt() @Min(1) @IsOptional() @@ -90,3 +89,115 @@ export class CheckEventChunk implements ListChunk<CheckEvent> { @ValidateNested({ each: true }) list: CheckEvent[]; } + +@ViewEntity({ + expression: (connection) => + connection + .createQueryBuilder() + .from(CheckEvent, "ce") + .groupBy("ce.user, ce.activityId") + .select("ce.user", "user") + .addSelect("ce.activityId", "activityId") + .addSelect("ce.activityName", "activityName") + .addSelect("COUNT(ce.id)", "checkCount"), +}) +export class UserActivityCheckInSummary { + @Type(() => User) + @ValidateNested() + @ViewColumn() + user: User; + + @ViewColumn() + activityId: string; + + @ViewColumn() + activityName: string; + + @ViewColumn() + checkCount: number; +} + +@ViewEntity({ + expression: (connection) => + connection + .createQueryBuilder() + .from(CheckEvent, "ce") + .groupBy("ce.activityId, ce.agendaId") + .select("ce.activityId", "activityId") + .addSelect("ce.activityName", "activityName") + .addSelect("ce.agendaId", "agendaId") + .addSelect("ce.agendaTitle", "agendaTitle") + .addSelect("COUNT(ce.id)", "checkCount"), +}) +export class ActivityAgendaCheckInSummary { + @ViewColumn() + activityId: string; + + @ViewColumn() + activityName: string; + + @ViewColumn() + agendaId: string; + + @ViewColumn() + agendaTitle: string; + + @ViewColumn() + checkCount: number; +} + +@ViewEntity({ + expression: (connection) => + connection + .createQueryBuilder() + .from(CheckEvent, "ce") + .groupBy("ce.activityId") + .select("ce.activityId", "activityId") + .addSelect("ce.activityName", "activityName") + .addSelect("COUNT(ce.id)", "checkCount"), +}) +export class ActivityCheckInSummary { + @IsInt() + @Min(1) + @ViewColumn() + activityId: number; + + @ViewColumn() + activityName: string; + + @ViewColumn() + checkCount: number; +} + +export class UserActivityCheckInListChunk + implements ListChunk<UserActivityCheckInSummary> { + @IsInt() + @Min(0) + count: number; + + @Type(() => UserActivityCheckInSummary) + @ValidateNested({ each: true }) + list: UserActivityCheckInSummary[]; +} + +export class ActivityAgendaCheckInListChunk + implements ListChunk<ActivityAgendaCheckInSummary> { + @IsInt() + @Min(0) + count: number; + + @Type(() => ActivityAgendaCheckInSummary) + @ValidateNested({ each: true }) + list: ActivityAgendaCheckInSummary[]; +} + +export class ActivityCheckInListChunk + implements ListChunk<ActivityCheckInSummary> { + @IsInt() + @Min(0) + count: number; + + @Type(() => ActivityCheckInSummary) + @ValidateNested({ each: true }) + list: ActivityCheckInSummary[]; +}