Skip to content
Merged
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,13 @@
-- CreateTable
CREATE TABLE "platform_settings" (
"id" INTEGER NOT NULL DEFAULT 1,
"stipend_month_1" DECIMAL(12,2) NOT NULL DEFAULT 30,
"stipend_month_2" DECIMAL(12,2) NOT NULL DEFAULT 50,
"stipend_month_3" DECIMAL(12,2) NOT NULL DEFAULT 100,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,

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

-- Seed the default singleton row
INSERT INTO "platform_settings" ("id") VALUES (1);
11 changes: 11 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,17 @@ model Payment {
@@map("payments")
}

// Singleton platform-wide configuration. There is always exactly one row (id=1).
model PlatformSetting {
id Int @id @default(1)
stipendMonth1 Decimal @default(30) @map("stipend_month_1") @db.Decimal(12, 2)
stipendMonth2 Decimal @default(50) @map("stipend_month_2") @db.Decimal(12, 2)
stipendMonth3 Decimal @default(100) @map("stipend_month_3") @db.Decimal(12, 2)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz

@@map("platform_settings")
}

// Per-student delivery settings, one row per user. A missing row means every
// channel is on (see the defaults below) — rows are created lazily on first edit.
model NotificationPreference {
Expand Down
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { NotificationsModule } from '@modules/notifications/notifications.module
import { SubmissionsModule } from '@modules/submissions/submissions.module';
import { TasksModule } from '@modules/tasks/tasks.module';
import { UsersModule } from '@modules/users/users.module';
import { SettingsModule } from '@modules/settings/settings.module';

@Module({
imports: [
Expand All @@ -34,6 +35,7 @@ import { UsersModule } from '@modules/users/users.module';
UsersModule,
DashboardModule,
NotificationsModule,
SettingsModule,
],
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
})
Expand Down
48 changes: 23 additions & 25 deletions backend/src/modules/account/account.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,36 +109,34 @@ export class AccountController {
};
const hasSocials = Object.values(socialFields).some((v) => v !== undefined);

const SOCIALS_SELECT = {
linkedin: true,
twitter: true,
facebook: true,
github: true,
portfolio: true,
telegram: true,
whatsapp: true,
} as const;

const [updated, socials] = await Promise.all([
this.prisma.user.update({ where: { id: user.id }, data: userFields }),
hasSocials
? this.prisma.application
.updateMany({ where: { userId: user.id }, data: socialFields })
.then(() =>
this.prisma.application.findUnique({
where: { userId: user.id },
select: {
linkedin: true,
twitter: true,
facebook: true,
github: true,
portfolio: true,
telegram: true,
whatsapp: true,
},
}),
)
: this.prisma.application.findUnique({
? this.prisma.application.upsert({
where: { userId: user.id },
select: {
linkedin: true,
twitter: true,
facebook: true,
github: true,
portfolio: true,
telegram: true,
whatsapp: true,
update: socialFields,
create: {
userId: user.id,
email: user.email,
firstName: user.name?.split(' ')[0] ?? '',
lastName: user.name?.split(' ').slice(1).join(' ') ?? '',
...socialFields,
},
select: SOCIALS_SELECT,
})
: this.prisma.application.findUnique({
where: { userId: user.id },
select: SOCIALS_SELECT,
}),
]);

Expand Down
14 changes: 12 additions & 2 deletions backend/src/modules/dashboard/dashboard.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export interface MonthlyPayment {
approvedThisMonth: number;
paymentDate: string;
eligible: boolean;
enrollmentMonth: number;
}

export interface StudentDashboard {
Expand Down Expand Up @@ -118,7 +119,7 @@ export class DashboardService {
typeBreakdown: [],
weeklyActivity: this.buildWeeklyActivity([]),
},
monthlyPayment: this.buildMonthlyPayment([], new Set()),
monthlyPayment: this.buildMonthlyPayment([], new Set(), new Date()),
};
}

Expand Down Expand Up @@ -214,13 +215,18 @@ export class DashboardService {
typeBreakdown,
weeklyActivity: this.buildWeeklyActivity(mySubmissions),
},
monthlyPayment: this.buildMonthlyPayment(tasks, approvedIds),
monthlyPayment: this.buildMonthlyPayment(
tasks,
approvedIds,
selected.enrolledAt,
),
};
}

private buildMonthlyPayment(
tasks: { id: number; dueDate: Date | null }[],
approvedIds: Set<number>,
enrolledAt: Date,
): MonthlyPayment {
const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
Expand All @@ -239,13 +245,17 @@ export class DashboardService {
const approvedThisMonth = tasksThisMonth.filter((t) =>
approvedIds.has(t.id),
).length;
const monthsElapsed =
(now.getFullYear() - enrolledAt.getFullYear()) * 12 +
(now.getMonth() - enrolledAt.getMonth());
return {
tasksThisMonth: tasksThisMonth.length,
approvedThisMonth,
paymentDate: monthEnd.toISOString(),
eligible:
tasksThisMonth.length > 0 &&
approvedThisMonth === tasksThisMonth.length,
enrollmentMonth: monthsElapsed + 1,
};
}

Expand Down
18 changes: 18 additions & 0 deletions backend/src/modules/settings/dto/update-settings.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { IsNumber, IsOptional, Min } from 'class-validator';

export class UpdateSettingsDto {
@IsOptional()
@IsNumber()
@Min(0.01)
stipendMonth1?: number;

@IsOptional()
@IsNumber()
@Min(0.01)
stipendMonth2?: number;

@IsOptional()
@IsNumber()
@Min(0.01)
stipendMonth3?: number;
}
23 changes: 23 additions & 0 deletions backend/src/modules/settings/settings.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Body, Controller, Get, Patch } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';

import { Roles } from '@core/auth/roles.decorator';
import { UpdateSettingsDto } from './dto/update-settings.dto';
import { SettingsService, type SettingsDto } from './settings.service';

@ApiTags('settings')
@Controller('settings')
export class SettingsController {
constructor(private readonly service: SettingsService) {}

@Get()
get(): Promise<SettingsDto> {
return this.service.get();
}

@Roles('admin')
@Patch()
update(@Body() dto: UpdateSettingsDto): Promise<SettingsDto> {
return this.service.update(dto);
}
}
9 changes: 9 additions & 0 deletions backend/src/modules/settings/settings.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { SettingsController } from './settings.controller';
import { SettingsService } from './settings.service';

@Module({
controllers: [SettingsController],
providers: [SettingsService],
})
export class SettingsModule {}
50 changes: 50 additions & 0 deletions backend/src/modules/settings/settings.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Injectable } from '@nestjs/common';
import type { PlatformSetting } from '@prisma/client';
import { PrismaService } from '@core/database/prisma.service';
import { UpdateSettingsDto } from './dto/update-settings.dto';

export interface SettingsDto {
stipendMonth1: string;
stipendMonth2: string;
stipendMonth3: string;
}

@Injectable()
export class SettingsService {
constructor(private readonly prisma: PrismaService) {}

async get(): Promise<SettingsDto> {
const row = await this.prisma.platformSetting.upsert({
where: { id: 1 },
update: {},
create: { id: 1 },
});
return this.serialize(row);
}

async update(dto: UpdateSettingsDto): Promise<SettingsDto> {
const row = await this.prisma.platformSetting.upsert({
where: { id: 1 },
update: {
...(dto.stipendMonth1 != null && { stipendMonth1: dto.stipendMonth1 }),
...(dto.stipendMonth2 != null && { stipendMonth2: dto.stipendMonth2 }),
...(dto.stipendMonth3 != null && { stipendMonth3: dto.stipendMonth3 }),
},
create: {
id: 1,
stipendMonth1: dto.stipendMonth1 ?? 30,
stipendMonth2: dto.stipendMonth2 ?? 50,
stipendMonth3: dto.stipendMonth3 ?? 100,
},
});
return this.serialize(row);
}

private serialize(row: PlatformSetting): SettingsDto {
return {
stipendMonth1: row.stipendMonth1.toString(),
stipendMonth2: row.stipendMonth2.toString(),
stipendMonth3: row.stipendMonth3.toString(),
};
}
}
23 changes: 18 additions & 5 deletions backend/test/unit/modules/account/account.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ const user = makeUser();
describe('AccountController', () => {
let controller: AccountController;
let prisma: {
application: { findUnique: jest.Mock; updateMany: jest.Mock };
application: {
findUnique: jest.Mock;
updateMany: jest.Mock;
upsert: jest.Mock;
};
user: { update: jest.Mock };
enrollment: { findMany: jest.Mock };
};
Expand All @@ -39,6 +43,7 @@ describe('AccountController', () => {
application: {
findUnique: jest.fn().mockResolvedValue(null),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
upsert: jest.fn().mockResolvedValue(null),
},
user: { update: jest.fn().mockResolvedValue(makeUser()) },
enrollment: { findMany: jest.fn().mockResolvedValue([]) },
Expand Down Expand Up @@ -102,17 +107,25 @@ describe('AccountController', () => {
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 2 }, data: { name: 'Ada B' } }),
);
// No social field provided, so socials are not written.
expect(prisma.application.updateMany).not.toHaveBeenCalled();
// No social field provided, so upsert is not called.
expect(prisma.application.upsert).not.toHaveBeenCalled();
});

it('updates the application socials when a social field is present', async () => {
it('upserts the application socials when a social field is present', async () => {
await controller.updateProfile(user, {
name: 'Ada',
github: 'https://github.com/ada',
});

expect(prisma.application.updateMany).toHaveBeenCalledTimes(1);
expect(prisma.application.upsert).toHaveBeenCalledTimes(1);
expect(prisma.application.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: 2 },
update: expect.objectContaining({
github: 'https://github.com/ada',
}) as unknown,
}),
);
});
});

Expand Down
24 changes: 20 additions & 4 deletions backend/test/unit/modules/dashboard/dashboard.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,11 @@ describe('DashboardService', () => {

it('aggregates task stats from the student submissions', async () => {
prisma.enrollment.findMany.mockResolvedValue([
{ cohortId: 3, cohort: makeCohort({ id: 3, name: 'Spring' }) },
{
cohortId: 3,
cohort: makeCohort({ id: 3, name: 'Spring' }),
enrolledAt: DATE,
},
]);
prisma.task.findMany.mockResolvedValue([
makeTask({ id: 1 }),
Expand Down Expand Up @@ -149,7 +153,11 @@ describe('DashboardService', () => {

it('counts a task with no submission as todo in the status breakdown', async () => {
prisma.enrollment.findMany.mockResolvedValue([
{ cohortId: 3, cohort: makeCohort({ id: 3, name: 'Spring' }) },
{
cohortId: 3,
cohort: makeCohort({ id: 3, name: 'Spring' }),
enrolledAt: DATE,
},
]);
prisma.task.findMany.mockResolvedValue([
makeTask({ id: 1 }),
Expand All @@ -171,8 +179,16 @@ describe('DashboardService', () => {

it('falls back to the most recent enrollment for an unknown cohortId', async () => {
prisma.enrollment.findMany.mockResolvedValue([
{ cohortId: 7, cohort: makeCohort({ id: 7, name: 'Newest' }) },
{ cohortId: 3, cohort: makeCohort({ id: 3, name: 'Older' }) },
{
cohortId: 7,
cohort: makeCohort({ id: 7, name: 'Newest' }),
enrolledAt: DATE,
},
{
cohortId: 3,
cohort: makeCohort({ id: 3, name: 'Older' }),
enrolledAt: DATE,
},
]);
prisma.task.findMany.mockResolvedValue([]);
prisma.submission.findMany.mockResolvedValue([]);
Expand Down
Loading
Loading