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
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ apps/*/.env.*

# اسکریپت‌های دیپلوی خودشان بخشی از اپ نیستند.
deploy
!deploy/prisma-engines
docker
docs
AUDIT
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,6 @@ deploy/env/app.env
demo-admin-credentials*
*.credentials.txt
DEPLOY/

deploy/prisma-engines/*
!deploy/prisma-engines/.gitkeep
17 changes: 0 additions & 17 deletions apps/api/prisma/demo-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,23 +507,6 @@ async function createDemo(tx: Tx, adminHash: string, adminIdentity: string, admi
attendanceTeacher: completed ? true : null,
},
});
await tx.payment.create({
data: {
id: `${PREFIX}payment-${i}`,
bookingId,
userId: `${PREFIX}student-${student[0]}`,
purpose: 'BOOKING',
referenceId: bookingId,
subtotal: teacher.price,
gatewayAmount: teacher.price,
amount: teacher.price,
status: 'PAID',
authority: `${PREFIX}authority-${i}`,
gatewayReference: `${PREFIX}gateway-${i}`,
idempotencyKey: `${PREFIX}payment-${i}`,
verifiedAt: at(completed ? -30 + i * 2 : -2),
},
});
if (completed)
await tx.review.create({
data: {
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/architecture.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ describe('module boundaries', () => {
expect(exported.sort()).toEqual([
'AutoDiscountsService',
'EarningsService',
'PackagesService',
'WalletService',
'releaseDiscount',
]);
Expand Down
5 changes: 4 additions & 1 deletion apps/api/src/modules/bookings/booking-jobs.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ export class BookingJobsService {
}

async scheduleBooking(bookingId: string, startsAt: Date) {
for (const [minutes, type] of [[1440, '24h'], [60, '1h']] as const) {
// Keep the short, user-facing reminder in the same durable queue as the
// longer reminders. This is also the reminder that powers the in-app class
// toast and carries the meeting URL in the notification payload.
for (const [minutes, type] of [[1440, '24h'], [60, '1h'], [15, '15m']] as const) {
const scheduledAt = new Date(startsAt.getTime() - minutes * 60e3);
if (scheduledAt <= new Date()) continue;
const reminder = await this.db.reminder.upsert({
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/modules/commerce/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ export { EarningsService } from './payouts/earnings.service';
export { WalletService } from './payments/wallet.service';
export { AutoDiscountsService } from './discounts/auto-discounts.service';
export { releaseDiscount } from './discounts/discount-reservation';
export { PackagesService } from './packages/packages.service';
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,12 @@ describe('ReceiptTopUpsService', () => {
expect(h.payments.settleVerified).not.toHaveBeenCalled();
});

it('an admin cannot approve their own receipt', async () => {
const h = harness({ payment: { ...pending, userId: 'admin-1' } });
await expect(h.svc.approve('admin-1', 'p-1')).rejects.toMatchObject({
response: { code: 'RECEIPT_SELF_REVIEW_FORBIDDEN' },
it('an admin can approve their own course receipt', async () => {
const h = harness({ payment: { ...pending, userId: 'admin-1', purpose: 'course' } });
await h.svc.approve('admin-1', 'p-1');
expect(h.payments.settleVerified).toHaveBeenCalledWith('p-1', undefined, {
method: 'receipt',
approvedBy: 'admin-1',
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,11 @@ export class ReceiptTopUpsService {
private async claim(actorId: string, paymentId: string, rejectReason: string | null) {
const payment = await this.db.payment.findUnique({ where: { id: paymentId } });
if (!payment || !payment.receiptFileId) throw notFound('PAYMENT_NOT_FOUND');
if (payment.userId === actorId) throw badRequest('RECEIPT_SELF_REVIEW_FORBIDDEN');
// Course purchases are reviewed by the course administrator, who may also
// be the purchaser. Keep the self-review guard for ordinary wallet top-ups.
if (payment.userId === actorId && payment.purpose !== 'course') {
throw badRequest('RECEIPT_SELF_REVIEW_FORBIDDEN');
}
const claimed = await this.db.payment.updateMany({
where: { id: paymentId, status: 'PENDING', reviewedAt: null },
data: {
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/modules/courses/courses.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Prisma } from '@prisma/client';
import { PrismaService, type DbClient } from '../../infrastructure/database/prisma.service';
import { badRequest, conflict, forbidden, notFound } from '../../common';
import type { AuthUser } from '../../common';
import { PackagesService } from '../commerce/packages/packages.service';
import { PackagesService } from '../commerce';
import type { CourseChapterDto, CourseLessonDto } from './dto/course-curriculum.dto';
import type { AdminCourseDto } from './dto/admin-course.dto';
import type { InstructorCourseDto } from './dto/instructor-course.dto';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Controller, Get, Param, Put } from '@nestjs/common';
import { CurrentUser, type AuthUser } from '../../common';
import { CurrentUser, Roles, type AuthUser } from '../../common';
import { NotificationsService } from './notifications.service';

@Controller('notifications')
Expand All @@ -8,6 +8,10 @@ export class NotificationsController {
@Get() list(@CurrentUser() u: AuthUser) {
return this.s.list(u.id);
}
@Roles('STUDENT', 'INSTRUCTOR', 'ADMIN', 'SUPPORT')
@Put('read-all') readAll(@CurrentUser() u: AuthUser) {
return this.s.readAll(u.id);
}
@Put(':id/read') read(@CurrentUser() u: AuthUser, @Param('id') id: string) {
return this.s.read(u.id, id);
}
Expand Down
14 changes: 13 additions & 1 deletion apps/api/src/modules/notifications/notifications.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,21 @@ export class NotificationsService {
});
}
list(userId: string) {
return this.db.notification.findMany({ where: { userId }, include: { deliveries: true }, orderBy: { createdAt: 'desc' }, take: 100 });
return this.db.notification.findMany({ where: { userId }, include: { deliveries: true }, orderBy: { createdAt: 'desc' }, take: 100 }).then((items) =>
items.map((item) => ({
...item,
// Keep the bilingual fields for existing clients while exposing the
// normalized notification contract used by the web app.
title: item.titleFa,
message: item.bodyFa,
link: item.data && typeof item.data === 'object' && !Array.isArray(item.data) && typeof item.data.link === 'string' ? item.data.link : null,
})),
);
}
read(userId: string, id: string) {
return this.db.notification.updateMany({ where: { id, userId }, data: { readAt: new Date() } });
}
readAll(userId: string) {
return this.db.notification.updateMany({ where: { userId, readAt: null }, data: { readAt: new Date() } });
}
}
24 changes: 24 additions & 0 deletions apps/web/e2e/open-three-roles.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { test } from '@playwright/test';
import jwt from 'jsonwebtoken';

test('open student teacher admin tabs', async ({ browser }) => {
const secret = process.env.JWT_ACCESS_SECRET;
if (!secret) throw new Error('JWT_ACCESS_SECRET is required');
const roles = [
{ name: 'student', id: 'user-student-completed', roles: ['STUDENT'], path: '/dashboard/classes' },
{ name: 'teacher', id: 'user-teacher-shahriar', roles: ['INSTRUCTOR'], path: '/teacher-panel/courses' },
{ name: 'admin', id: 'user-admin', roles: ['ADMIN'], path: '/admin' },
] as const;
const context = await browser.newContext();
const pages = await Promise.all(roles.map(async (role) => {
const page = await context.newPage();
const token = jwt.sign({ id: role.id, roles: role.roles, permissions: ['payments.read', 'payments.adjust-wallet', 'bookings.read', 'courses.manage'] }, secret, { expiresIn: '2h' });
await page.addInitScript((value) => sessionStorage.setItem('access_token', value), token);
await page.goto(role.path, { waitUntil: 'domcontentloaded' });
await page.screenshot({ path: `test-results/roles-${role.name}.png`, fullPage: true });
console.log(`${role.name}: ${page.url()}`);
return page;
}));
await pages[0].bringToFront();
await pages[0].pause();
});
4 changes: 3 additions & 1 deletion apps/web/e2e/panels.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@ test('student panel routes and data widgets render', async ({ page }) => {
test('teacher panel routes and data widgets render', async ({ page }) => {
test.setTimeout(90_000);
const errors = failures(page);
await session(page, 'user-teacher-approved', ['TEACHER']);
// The persisted domain role is INSTRUCTOR (not the display label “Teacher”).
// Keeping the fixture aligned with the API enum exercises the real teacher guard.
await session(page, 'user-teacher-shahriar', ['INSTRUCTOR']);
await visit(page, [
'/teacher-panel',
'/teacher-panel/profile',
Expand Down
29 changes: 29 additions & 0 deletions apps/web/e2e/visual-class-reminder.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { expect, test } from '@playwright/test';
import jwt from 'jsonwebtoken';

test('visual confirmed class, calendar link and reminder', async ({ page }) => {
const secret = process.env.JWT_ACCESS_SECRET;
if (!secret) throw new Error('JWT_ACCESS_SECRET is required');
const token = jwt.sign({ id: 'user-student-completed', roles: ['STUDENT'], permissions: [] }, secret, { expiresIn: '15m' });
await page.addInitScript((value) => sessionStorage.setItem('access_token', value), token);
const responses: string[] = [];
page.on('response', (response) => {
if (response.url().includes('/api/')) responses.push(`${response.status()} ${response.request().method()} ${response.url()}`);
});
await page.goto('/dashboard/classes');
await page.waitForTimeout(3000);
await page.screenshot({ path: 'test-results/class-01-calendar.png', fullPage: true });
const meetingLink = page.getByRole('link', { name: /Google Meet/ });
await expect(meetingLink).toBeVisible();
await expect(meetingLink).toHaveAttribute('href', 'https://meet.google.com/e2e-clock-20260930');
await page.screenshot({ path: 'test-results/class-03-before-join.png', fullPage: true });
await meetingLink.click({ noWaitAfter: true });
await page.waitForTimeout(3000);
await page.goto('/dashboard/notifications');
await page.waitForLoadState('networkidle');
await page.screenshot({ path: 'test-results/class-02-notification.png', fullPage: true });
await expect(page.getByText('یادآوری کلاس').first()).toBeVisible();
await expect(page.getByText('https://meet.google.com/e2e-clock-20260930').first()).toBeVisible();
console.log(responses.join('\n'));
if (process.env.KEEP_BROWSER_OPEN === 'true') await page.pause();
});
47 changes: 47 additions & 0 deletions apps/web/e2e/visual-guided-flow.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { expect, test } from '@playwright/test';
import jwt from 'jsonwebtoken';

test('guided visible multi-role course flow', async ({ browser }) => {
test.setTimeout(180_000);
const secret = process.env.JWT_ACCESS_SECRET;
if (!secret) throw new Error('JWT_ACCESS_SECRET is required');
const context = await browser.newContext();
const adminPermissions = ['users.read','users.manage','teachers.read','teachers.verify','teacher-prices.manage','languages.manage','tests.manage','tests.review','bookings.read','bookings.manage','tickets.read','tickets.manage','payments.read','payments.refund','payments.adjust-wallet','payouts.manage','reviews.manage','courses.manage','audit.read','settings.manage','cms.manage','notifications.read','roles.manage','reports.read','availability.manage'];
async function tab(id: string, roles: string[], path: string) {
const page = await context.newPage();
const token = jwt.sign({ id, roles, permissions: roles.includes('ADMIN') ? adminPermissions : [] }, secret, { expiresIn: '2h' });
await page.addInitScript((value) => sessionStorage.setItem('access_token', value), token);
page.on('response', (r) => { if (r.url().includes('/api/')) console.log(`${id}: ${r.status()} ${r.request().method()} ${r.url()}`); });
await page.goto(path, { waitUntil: 'domcontentloaded', timeout: 30_000 });
await page.waitForTimeout(4_000);
return page;
}
const teacher = await tab('teacher', ['INSTRUCTOR'], '/teacher-panel/courses');
await teacher.screenshot({ path: 'test-results/guided-01-teacher-course.png', fullPage: true });
await teacher.waitForTimeout(8_000);
const admin = await tab('admin', ['ADMIN'], '/admin/courses');
await admin.screenshot({ path: 'test-results/guided-02-admin-course-approval.png', fullPage: true });
await admin.waitForTimeout(8_000);
await admin.goto('/admin/teacher-prices', { waitUntil: 'domcontentloaded' });
await admin.waitForTimeout(4_000);
await admin.screenshot({ path: 'test-results/guided-03-admin-prices.png', fullPage: true });
await admin.waitForTimeout(8_000);
await admin.goto('/admin/finance', { waitUntil: 'domcontentloaded' });
await admin.waitForTimeout(4_000);
await admin.screenshot({ path: 'test-results/guided-04-admin-finance.png', fullPage: true });
await admin.waitForTimeout(8_000);
const student = await tab('student', ['STUDENT'], '/courses/e2e-live-course-1790715303832');
await student.screenshot({ path: 'test-results/guided-05-student-course.png', fullPage: true });
await student.waitForTimeout(8_000);
await student.goto('/dashboard/classes', { waitUntil: 'domcontentloaded' });
await student.waitForTimeout(4_000);
await student.screenshot({ path: 'test-results/guided-06-student-calendar.png', fullPage: true });
await student.goto('/dashboard/notifications', { waitUntil: 'domcontentloaded' });
await student.waitForTimeout(4_000);
await student.screenshot({ path: 'test-results/guided-07-student-notifications.png', fullPage: true });
await student.bringToFront();
// Keep the final state visible briefly, then finish instead of waiting on
// Playwright Inspector's pause indefinitely.
await student.waitForTimeout(15_000);
expect(true).toBeTruthy();
});
43 changes: 43 additions & 0 deletions apps/web/e2e/visual-one-tab-flow.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { expect, test } from '@playwright/test';
import jwt from 'jsonwebtoken';

test('complete visible flow in one browser tab', async ({ page }) => {
test.setTimeout(120_000);
const secret = process.env.JWT_ACCESS_SECRET;
if (!secret) throw new Error('JWT_ACCESS_SECRET is required');
page.setDefaultNavigationTimeout(30_000);
page.on('response', (r) => { if (r.url().includes('/api/')) console.log(`${r.status()} ${r.request().method()} ${r.url()}`); });
const permissions = ['users.read','users.manage','teachers.read','teachers.verify','teacher-prices.manage','languages.manage','tests.manage','tests.review','bookings.read','bookings.manage','tickets.read','tickets.manage','payments.read','payments.refund','payments.adjust-wallet','payouts.manage','reviews.manage','courses.manage','audit.read','settings.manage','cms.manage','notifications.read','roles.manage','reports.read','availability.manage'];
async function switchRole(id: string, roles: string[], path: string) {
const token = jwt.sign({ id, roles, permissions: roles.includes('ADMIN') ? permissions : [] }, secret, { expiresIn: '2h' });
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.evaluate((value) => sessionStorage.setItem('access_token', value), token);
await page.goto(path, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(4_000);
}
await switchRole('user-teacher-shahriar', ['INSTRUCTOR'], '/teacher-panel/courses');
await page.screenshot({ path: 'test-results/one-tab-01-teacher.png', fullPage: true });
await page.waitForTimeout(5_000);
await switchRole('user-admin', ['ADMIN'], '/admin/courses');
await page.screenshot({ path: 'test-results/one-tab-02-admin-course.png', fullPage: true });
await page.waitForTimeout(5_000);
await page.goto('/admin/teacher-prices', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(3_000);
await page.screenshot({ path: 'test-results/one-tab-03-admin-prices.png', fullPage: true });
await page.goto('/admin/finance', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(3_000);
await page.screenshot({ path: 'test-results/one-tab-04-admin-finance.png', fullPage: true });
await page.waitForTimeout(5_000);
await switchRole('user-student-completed', ['STUDENT'], '/courses/e2e-live-course-1790715303832');
await page.screenshot({ path: 'test-results/one-tab-05-student-course.png', fullPage: true });
await page.goto('/dashboard/classes', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(3_000);
await expect(page.getByRole('link', { name: /Google Meet/ })).toBeVisible({ timeout: 15_000 });
await page.screenshot({ path: 'test-results/one-tab-06-calendar.png', fullPage: true });
await page.getByRole('link', { name: /Google Meet/ }).click({ noWaitAfter: true });
await page.waitForTimeout(3_000);
await page.goto('/dashboard/notifications', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(3_000);
await page.screenshot({ path: 'test-results/one-tab-07-notifications.png', fullPage: true });
expect(true).toBeTruthy();
});
33 changes: 33 additions & 0 deletions apps/web/e2e/visual-placement.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { expect, test } from '@playwright/test';

test('visual placement flow', async ({ page }) => {
const apiResponses: string[] = [];
page.on('response', (response) => {
if (response.url().includes('/api/')) apiResponses.push(`${response.status()} ${response.request().method()} ${response.url()}`);
});
page.on('console', (message) => console.log(`[browser:${message.type()}] ${message.text()}`));
await page.goto('/placement');
await page.screenshot({ path: 'test-results/visual-01-placement-entry.png', fullPage: true });
await expect(page.getByRole('heading', { name: 'آزمون تعیین سطح زبان' })).toBeVisible();

const language = page.locator('.placement-language-card').first();
await language.click();
await page.screenshot({ path: 'test-results/visual-02-language-selected.png', fullPage: true });
const start = page.locator('.placement-start-button').first();
await expect(start).toBeVisible();
await start.click();
await page.screenshot({ path: 'test-results/visual-03-test-started.png', fullPage: true });

while (await page.locator('.placement-question-card').count()) {
const option = page.locator('.placement-option').first();
await option.click();
const next = page.locator('.placement-primary-action');
await next.click();
await page.waitForTimeout(100);
if (await page.locator('.placement-result').count()) break;
}
await page.waitForTimeout(500);
await page.screenshot({ path: 'test-results/visual-04-placement-result.png', fullPage: true });
console.log(apiResponses.join('\n'));
await expect(page.locator('body')).not.toContainText('Internal Server Error');
});
Loading
Loading