Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "ApplicationStatusLog" (
"id" SERIAL NOT NULL,
"applicationId" INTEGER NOT NULL,
"changedById" TEXT,
"previousStatus" "ApplicationStatus",
"newStatus" "ApplicationStatus" NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "ApplicationStatusLog_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "ApplicationStatusLog_applicationId_idx" ON "ApplicationStatusLog"("applicationId");

-- CreateIndex
CREATE INDEX "ApplicationStatusLog_changedById_idx" ON "ApplicationStatusLog"("changedById");

-- AddForeignKey
ALTER TABLE "ApplicationStatusLog" ADD CONSTRAINT "ApplicationStatusLog_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "Application"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "ApplicationStatusLog" ADD CONSTRAINT "ApplicationStatusLog_changedById_fkey" FOREIGN KEY ("changedById") REFERENCES "User"("authSchId") ON DELETE SET NULL ON UPDATE CASCADE;
70 changes: 44 additions & 26 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -34,26 +34,27 @@ enum ApplicationStatus {
}

model User {
authSchId String @id
fullName String
nickName String
role Role @default(USER)
neptun String? @unique
email String?
isSchResident Boolean
isActiveVikStudent Boolean
roomNumber Int?
canHelpNoobs Boolean @default(false)
publicDesc String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
vikStatusUpdatedAt DateTime @default(now())
profilePicture ProfilePicture?
applicationPeriods ApplicationPeriod[]
applications Application[]
posts Post[]
upvotes Upvote[]
idNumber String?
authSchId String @id
fullName String
nickName String
role Role @default(USER)
neptun String? @unique
email String?
isSchResident Boolean
isActiveVikStudent Boolean
roomNumber Int?
canHelpNoobs Boolean @default(false)
publicDesc String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
vikStatusUpdatedAt DateTime @default(now())
profilePicture ProfilePicture?
applicationPeriods ApplicationPeriod[]
applications Application[]
posts Post[]
upvotes Upvote[]
idNumber String?
applicationStatusChanges ApplicationStatusLog[]
}

model ProfilePicture {
Expand Down Expand Up @@ -100,18 +101,35 @@ model ApplicationPeriod {
}

model Application {
id Int @id @default(autoincrement())
user User @relation(fields: [userId], references: [authSchId])
id Int @id @default(autoincrement())
user User @relation(fields: [userId], references: [authSchId])
userId String
applicationPeriod ApplicationPeriod @relation(fields: [applicationPeriodId], references: [id], onDelete: Cascade)
applicationPeriod ApplicationPeriod @relation(fields: [applicationPeriodId], references: [id], onDelete: Cascade)
applicationPeriodId Int
status ApplicationStatus @default(SUBMITTED)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
status ApplicationStatus @default(SUBMITTED)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
statusLogs ApplicationStatusLog[]

@@unique([userId, applicationPeriodId])
}

/// Audit log: one row per status change of an Application.
/// Records who made the change, when, and the previous/new status.
model ApplicationStatusLog {
id Int @id @default(autoincrement())
application Application @relation(fields: [applicationId], references: [id], onDelete: Cascade)
applicationId Int
changedBy User? @relation(fields: [changedById], references: [authSchId], onDelete: SetNull)
changedById String?
previousStatus ApplicationStatus?
newStatus ApplicationStatus
createdAt DateTime @default(now())

@@index([applicationId])
@@index([changedById])
}

model Post {
id Int @id @default(autoincrement())
title String
Expand Down
25 changes: 21 additions & 4 deletions apps/backend/src/application/application.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ export class ApplicationController {
return this.applicationService.getLastUserApplication(user);
}

@UseGuards(AuthGuard('jwt'), RolesGuard)
@ApiBearerAuth()
@Roles(Role.BODY_ADMIN, Role.BODY_MEMBER)
@Get('status-logs')
findAllStatusLogs() {
return this.applicationService.findAllStatusLogs();
}

@UseGuards(AuthGuard('jwt'), RolesGuard)
@ApiBearerAuth()
@Roles(Role.BODY_ADMIN, Role.BODY_MEMBER)
Expand All @@ -63,8 +71,16 @@ export class ApplicationController {
@ApiBearerAuth()
@Roles(Role.BODY_ADMIN, Role.BODY_MEMBER)
@Patch('bulk')
bulkUpdate(@Body() bulkUpdateApplicationDto: BulkUpdateApplicationDto) {
return this.applicationService.bulkUpdate(bulkUpdateApplicationDto);
bulkUpdate(@Body() bulkUpdateApplicationDto: BulkUpdateApplicationDto, @CurrentUser() user: User) {
return this.applicationService.bulkUpdate(bulkUpdateApplicationDto, user);
}

@UseGuards(AuthGuard('jwt'), RolesGuard)
@ApiBearerAuth()
@Roles(Role.BODY_ADMIN, Role.BODY_MEMBER)
@Get(':id/status-logs')
getStatusLogs(@Param('id', ParseIntPipe) id: number) {
return this.applicationService.getStatusLogs(id);
}

@UseGuards(AuthGuard('jwt'), RolesGuard)
Expand All @@ -73,9 +89,10 @@ export class ApplicationController {
@Patch(':id')
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateApplicationDto: UpdateApplicationDto
@Body() updateApplicationDto: UpdateApplicationDto,
@CurrentUser() user: User
): Promise<Application> {
return this.applicationService.update(id, updateApplicationDto);
return this.applicationService.update(id, updateApplicationDto, user);
}

@UseGuards(AuthGuard('jwt'), RolesGuard)
Expand Down
150 changes: 126 additions & 24 deletions apps/backend/src/application/application.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ import { CreateApplicationDto } from './dto/create-application.dto';
import { UpdateApplicationDto } from './dto/update-application.dto';
import { DefaultArgs } from '@prisma/client/runtime/library';

/**
* A Prisma client scoped to an interactive transaction (the value passed to the
* `$transaction(async (tx) => ...)` callback).
*/
export type PrismaTransactionClient = Omit<
PrismaClient<Prisma.PrismaClientOptions, never, DefaultArgs>,
'$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends'
>;

@Injectable()
export class ApplicationService {
constructor(
Expand Down Expand Up @@ -171,37 +180,137 @@ export class ApplicationService {
}
}

async update(id: number, updateApplicationDto: UpdateApplicationDto): Promise<Application> {
try {
return await this.prisma.application.update({
where: {
id,
},
/**
* Changes an application's status, refreshes its `updatedAt` timestamp and
* writes an audit-log entry (`ApplicationStatusLog`) recording who made the
* change and the previous/new status. A log entry is only written when the
* status actually changes.
*
* @param client - a Prisma client or an interactive transaction client
* @param application - the current application (must include `id` and `status`)
* @param newStatus - the status to set
* @param changedById - `authSchId` of the acting user, or `null` for system-initiated changes
*/
private async applyStatusChange(
client: PrismaService | PrismaTransactionClient,
application: Pick<Application, 'id' | 'status'>,
newStatus: ApplicationStatus,
changedById: string | null
): Promise<Application> {
const updated = await client.application.update({
where: { id: application.id },
data: {
status: newStatus,
updatedAt: new Date(),
},
});
if (application.status !== newStatus) {
await client.applicationStatusLog.create({
data: {
status: updateApplicationDto.applicationStatus,
applicationId: application.id,
previousStatus: application.status,
newStatus,
changedById,
},
});
}
return updated;
}

async update(id: number, updateApplicationDto: UpdateApplicationDto, user: User): Promise<Application> {
try {
return await this.prisma.$transaction(async (tx) => {
const application = await tx.application.findUniqueOrThrow({ where: { id } });
return this.applyStatusChange(tx, application, updateApplicationDto.applicationStatus, user.authSchId);
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2025') {
throw new NotFoundException('A keresett jelentkezés nem található');
}
}
throw e;
}
}

async bulkUpdate(bulkUpdateApplicationDto: BulkUpdateApplicationDto): Promise<Prisma.BatchPayload> {
const { ids, applicationStatus } = bulkUpdateApplicationDto;
return this.prisma.application.updateMany({
where: {
id: { in: ids },
/**
* Returns the whole status-change audit log (every application), newest first,
* including the affected application's applicant and who made each change.
* Used by the admin audit-log page, which does its own filtering/pagination.
*/
async findAllStatusLogs() {
return this.prisma.applicationStatusLog.findMany({
orderBy: { createdAt: 'desc' },
include: {
changedBy: { select: { authSchId: true, fullName: true, nickName: true } },
application: {
select: {
id: true,
applicationPeriodId: true,
user: { select: { authSchId: true, fullName: true, nickName: true } },
},
},
},
data: {
status: applicationStatus,
});
}

/**
* Returns the audit log of every status change of the given application,
* newest first, including who made each change.
*/
async getStatusLogs(id: number) {
try {
await this.prisma.application.findUniqueOrThrow({ where: { id } });
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please wrap your magic values in local constants

throw new NotFoundException('A keresett jelentkezés nem található');
}
throw e;
}
return this.prisma.applicationStatusLog.findMany({
where: { applicationId: id },
orderBy: { createdAt: 'desc' },
include: {
changedBy: { select: { authSchId: true, fullName: true, nickName: true } },
},
});
}

/**
* Bulk status change: refreshes `updatedAt` and writes an audit-log entry for
* every application whose status actually changes. Runs in three statements
* (read current statuses, `updateMany`, `createMany` the log rows) regardless
* of how many applications are targeted.
*/
async bulkUpdate(bulkUpdateApplicationDto: BulkUpdateApplicationDto, user: User): Promise<Prisma.BatchPayload> {
const { ids, applicationStatus } = bulkUpdateApplicationDto;
return this.prisma.$transaction(async (tx) => {
const applications = await tx.application.findMany({
where: { id: { in: ids } },
select: { id: true, status: true },
});

const result = await tx.application.updateMany({
where: { id: { in: ids } },
data: { status: applicationStatus, updatedAt: new Date() },
});

const logs = applications
.filter((application) => application.status !== applicationStatus)
.map((application) => ({
applicationId: application.id,
previousStatus: application.status,
newStatus: applicationStatus,
changedById: user.authSchId,
}));
if (logs.length > 0) {
await tx.applicationStatusLog.createMany({ data: logs });
}

return result;
});
}

async remove(id: number, user: User): Promise<Application> {
const application = await this.prisma.application.findFirst({
where: {
Expand Down Expand Up @@ -248,20 +357,13 @@ export class ApplicationService {
async setActiveApplicationsStatus(
userId: string,
status: ApplicationStatus,
tx: Omit<
PrismaClient<Prisma.PrismaClientOptions, never, DefaultArgs>,
'$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends'
>
tx: PrismaTransactionClient,
changedById: string | null
) {
try {
const activeApplications = await this.getActiveApplications(userId);
await Promise.all(
activeApplications.map((application) =>
tx.application.update({
where: { id: application.id },
data: { status: status },
})
)
activeApplications.map((application) => this.applyStatusChange(tx, application, status, changedById))
);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
Expand Down
17 changes: 11 additions & 6 deletions apps/backend/src/user/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,14 @@ export class UserController {
@UploadedFile(ImageParserPipe)
file: Express.Multer.File
) {
await this.userService.saveProfilePicture(user.authSchId, file.buffer, file.mimetype);
await this.userService.saveProfilePicture(user.authSchId, file.buffer, file.mimetype, user.authSchId);
}

@Delete('me/profile-picture')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async deleteProfilePicture(@CurrentUser() user: User) {
await this.userService.deleteProfilePicture(user.authSchId);
await this.userService.deleteProfilePicture(user.authSchId, user.authSchId);
}

@Patch('me')
Expand Down Expand Up @@ -131,8 +131,12 @@ export class UserController {
@UseGuards(AuthGuard('jwt'), RolesGuard)
@ApiBearerAuth()
@Roles(Role.BODY_ADMIN)
async setProfilePictureStatus(@Param('id') id: string, @Param('status') status: ProfilePictureStatus) {
return this.userService.setProfilePictureStatus(id, status);
async setProfilePictureStatus(
@Param('id') id: string,
@Param('status') status: ProfilePictureStatus,
@CurrentUser() user: User
) {
return this.userService.setProfilePictureStatus(id, status, user.authSchId);
}

@Post(':id/profile-picture')
Expand All @@ -143,8 +147,9 @@ export class UserController {
async updateProfilePictureAdmin(
@Param('id') id: string,
@UploadedFile(ImageParserPipe)
file: Express.Multer.File
file: Express.Multer.File,
@CurrentUser() user: User
) {
await this.userService.saveProfilePicture(id, file.buffer, file.mimetype);
await this.userService.saveProfilePicture(id, file.buffer, file.mimetype, user.authSchId);
}
}
Loading
Loading