Skip to content

Commit 00a9f17

Browse files
authored
Merge pull request #9 from GitAddRemote/feature/password-reset
feat: add password reset flow and tidy test noise
2 parents d2ab9c8 + 9d46c58 commit 00a9f17

28 files changed

Lines changed: 1765 additions & 26 deletions

AGENTS.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Repository Guidelines
2+
3+
## Project Structure & Module Organization
4+
5+
- Monorepo managed with pnpm + Turbo. Backend: `backend/` (NestJS app in `src/`, migrations in `src/migrations`, tests in `test/`). Frontend: `frontend/` (React/Vite code in `src/`).
6+
- Shared tooling at root (`turbo.json`, `pnpm-workspace.yaml`, `tsconfig.json`, husky hooks); infra assets live in `docker-compose.yml`, `k8s/`, and package `Dockerfile`s.
7+
- Copy env templates before running services: `backend/.env.example`, `frontend/.env.example`.
8+
9+
## Build, Test, and Development Commands
10+
11+
- Install once at root: `pnpm install`.
12+
- Develop both apps with `pnpm dev`; scope to a package via `pnpm --filter backend dev` or `pnpm --filter frontend dev`.
13+
- Build with `pnpm build` or scoped `pnpm --filter <pkg> build`.
14+
- Tests: `pnpm test` (all), `pnpm --filter backend test`, `pnpm --filter backend test:e2e`, `pnpm --filter backend test:cov`.
15+
- Lint/format/typecheck: `pnpm lint`, `pnpm format`, `pnpm typecheck`. Husky + lint-staged auto-format backend TS on commit.
16+
- Local data stack: `docker-compose up -d` (Postgres 5433, Redis 6379); run `pnpm --filter backend migration:run` after services are healthy.
17+
18+
## Coding Style & Naming Conventions
19+
20+
- TypeScript everywhere; follow ESLint configs (`backend/eslint.config.js`, `frontend/.eslintrc.cjs`) and Prettier defaults (2-space indent).
21+
- Naming: `camelCase` for vars/functions, `PascalCase` for classes/components, `SCREAMING_SNAKE_CASE` for constants/envs. Backend follows Nest patterns (`*.module.ts`, `*.service.ts`, `*.controller.ts`); React components live one per file.
22+
- Prefer typed DTOs/interfaces; use async/await with explicit HTTP exceptions and validation.
23+
24+
## Testing Guidelines
25+
26+
- Backend: Jest specs under `backend/test` or alongside code as `*.spec.ts`; E2E uses `backend/test/jest-e2e.json`. Cover new endpoints/services and add E2E for auth or database flows.
27+
- Frontend: use React Testing Library; place specs next to components as `*.test.tsx`.
28+
- Aim for passing coverage check via `pnpm --filter backend test:cov`; mock external calls in unit tests and reserve real integrations for E2E.
29+
30+
## Commit & Pull Request Guidelines
31+
32+
- Use concise, imperative commits; conventional prefixes (`feat:`, `fix:`, `ci:`) match existing history. One change-set per commit.
33+
- Before a PR: ensure `pnpm lint`, `pnpm test`, and relevant builds succeed; call out migrations or breaking changes.
34+
- PRs should include a short summary, linked issue, and testing notes. Add screenshots/GIFs for UI updates and flag security-sensitive changes (auth, tokens, RBAC).
35+
36+
## Security & Configuration Tips
37+
38+
- Keep secrets out of Git; rely on the env examples and local overrides. Rotate `JWT_SECRET` and database creds outside local dev.
39+
- Confirm CORS + HTTPS settings before deploying. For data debugging, prefer `docker-compose logs -f` and `pnpm --filter backend migration:revert` instead of manual DB edits.

README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,9 +164,13 @@ Run both frontend and backend in development mode:
164164
# From root directory
165165
pnpm dev
166166

167-
# Or individually:
168-
cd backend && pnpm dev # Backend on http://localhost:3001
169-
cd frontend && pnpm dev # Frontend on http://localhost:5173
167+
# Or individually from root:
168+
pnpm dev:backend # Backend on http://localhost:3001
169+
pnpm dev:frontend # Frontend on http://localhost:5173
170+
171+
# Or from package directories:
172+
cd backend && pnpm dev # Backend only
173+
cd frontend && pnpm dev # Frontend only
170174
```
171175

172176
**Note**: Redis is optional. The application will fall back to in-memory caching if Redis is unavailable.

backend/.env.test

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ DATABASE_NAME=stationDb
66
JWT_SECRET=test-jwt-secret-for-e2e-tests-only
77
PORT=3000
88
APP_NAME=STATION BACKEND TEST
9+
USE_REDIS_CACHE=false

backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"test:watch": "jest --watch",
2626
"test:cov": "jest --coverage",
2727
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
28-
"test:e2e": "jest --config ./test/jest-e2e.json",
28+
"test:e2e": "jest --config ./test/jest-e2e.json --runInBand",
2929
"clean": "rm -rf dist"
3030
},
3131
"dependencies": {

backend/src/app.module.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,20 @@ import { AuditLogsModule } from './modules/audit-logs/audit-logs.module';
2323
isGlobal: true,
2424
imports: [ConfigModule],
2525
useFactory: async (configService: ConfigService) => {
26+
const useRedis =
27+
configService.get<string>('USE_REDIS_CACHE', 'true') === 'true';
28+
29+
if (!useRedis) {
30+
return {
31+
store: 'memory',
32+
ttl: 300000,
33+
max: 100,
34+
// Disable interval cleanup to avoid open handles in tests
35+
checkperiod: 0,
36+
isCacheableValue: () => true,
37+
};
38+
}
39+
2640
try {
2741
const store = await redisStore({
2842
socket: {

backend/src/data-source.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ import { Organization } from './modules/organizations/organization.entity';
66
import { Role } from './modules/roles/role.entity';
77
import { UserOrganizationRole } from './modules/user-organization-roles/user-organization-role.entity';
88
import { RefreshToken } from './modules/auth/refresh-token.entity';
9+
import { PasswordReset } from './modules/auth/password-reset.entity';
910
import { AuditLog } from './modules/audit-logs/audit-log.entity';
1011
import { CreateUsersTable1716956654528 } from './migrations/1716956654528-CreateUsersTable';
1112
import { CreateOrganizationsRolesAndJunctionTable1730841000000 } from './migrations/1730841000000-CreateOrganizationsRolesAndJunctionTable';
1213
import { CreateAuditLogsTable1730900000000 } from './migrations/1730900000000-CreateAuditLogsTable';
1314
import { CreateRefreshTokenTable1731715200000 } from './migrations/1731715200000-CreateRefreshTokenTable';
1415
import { AddUserProfileFields1732000000000 } from './migrations/1732000000000-AddUserProfileFields';
16+
import { CreatePasswordResetsTable1732050000000 } from './migrations/1732050000000-CreatePasswordResetsTable';
1517

1618
export const AppDataSource = new DataSource({
1719
type: 'postgres',
@@ -26,6 +28,7 @@ export const AppDataSource = new DataSource({
2628
Role,
2729
UserOrganizationRole,
2830
RefreshToken,
31+
PasswordReset,
2932
AuditLog,
3033
],
3134
migrations: [
@@ -34,6 +37,7 @@ export const AppDataSource = new DataSource({
3437
CreateAuditLogsTable1730900000000,
3538
CreateRefreshTokenTable1731715200000,
3639
AddUserProfileFields1732000000000,
40+
CreatePasswordResetsTable1732050000000,
3741
],
3842
synchronize: false,
3943
});

backend/src/database/seeds/database-seeder.service.spec.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Test, TestingModule } from '@nestjs/testing';
22
import { getRepositoryToken } from '@nestjs/typeorm';
3+
import { Logger } from '@nestjs/common';
34
import { Repository } from 'typeorm';
45
import { DatabaseSeederService } from './database-seeder.service';
56
import { Role } from '../../modules/roles/role.entity';
@@ -18,6 +19,9 @@ describe('DatabaseSeederService', () => {
1819
let organizationsRepository: Repository<Organization>;
1920
let usersRepository: Repository<User>;
2021
let userOrgRolesRepository: Repository<UserOrganizationRole>;
22+
let loggerLogSpy: jest.SpyInstance;
23+
let loggerWarnSpy: jest.SpyInstance;
24+
let loggerErrorSpy: jest.SpyInstance;
2125

2226
const mockRole = {
2327
id: 1,
@@ -48,7 +52,29 @@ describe('DatabaseSeederService', () => {
4852
roleId: 1,
4953
};
5054

55+
beforeAll(() => {
56+
loggerLogSpy = jest
57+
.spyOn(Logger.prototype, 'log')
58+
.mockImplementation(() => undefined);
59+
loggerWarnSpy = jest
60+
.spyOn(Logger.prototype, 'warn')
61+
.mockImplementation(() => undefined);
62+
loggerErrorSpy = jest
63+
.spyOn(Logger.prototype, 'error')
64+
.mockImplementation(() => undefined);
65+
});
66+
67+
afterAll(() => {
68+
loggerLogSpy.mockRestore();
69+
loggerWarnSpy.mockRestore();
70+
loggerErrorSpy.mockRestore();
71+
});
72+
5173
beforeEach(async () => {
74+
loggerLogSpy.mockClear();
75+
loggerWarnSpy.mockClear();
76+
loggerErrorSpy.mockClear();
77+
5278
const module: TestingModule = await Test.createTestingModule({
5379
providers: [
5480
DatabaseSeederService,
@@ -155,6 +181,11 @@ describe('DatabaseSeederService', () => {
155181
.mockRejectedValue(new Error('Database error'));
156182

157183
await expect(service.seedAll()).rejects.toThrow('Database error');
184+
185+
expect(loggerErrorSpy).toHaveBeenCalledWith(
186+
'❌ Database seeding failed:',
187+
expect.any(Error),
188+
);
158189
});
159190
});
160191
});

backend/src/main.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
66
import * as figlet from 'figlet';
77
import * as dotenv from 'dotenv';
88
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
9-
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
109

1110
dotenv.config();
1211

@@ -35,9 +34,6 @@ async function bootstrap() {
3534
// Global Exception Filter for standardized error responses
3635
app.useGlobalFilters(new HttpExceptionFilter());
3736

38-
// Global Response Transform Interceptor for standardized success responses
39-
app.useGlobalInterceptors(new TransformInterceptor());
40-
4137
// Swagger/OpenAPI Documentation Setup
4238
const config = new DocumentBuilder()
4339
.setTitle('Station API')
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import {
2+
MigrationInterface,
3+
QueryRunner,
4+
Table,
5+
TableForeignKey,
6+
} from 'typeorm';
7+
8+
export class CreatePasswordResetsTable1732050000000
9+
implements MigrationInterface
10+
{
11+
public async up(queryRunner: QueryRunner): Promise<void> {
12+
await queryRunner.createTable(
13+
new Table({
14+
name: 'password_resets',
15+
columns: [
16+
{
17+
name: 'id',
18+
type: 'int',
19+
isPrimary: true,
20+
isGenerated: true,
21+
generationStrategy: 'increment',
22+
},
23+
{
24+
name: 'userId',
25+
type: 'int',
26+
isNullable: false,
27+
},
28+
{
29+
name: 'token',
30+
type: 'varchar',
31+
length: '255',
32+
isUnique: true,
33+
isNullable: false,
34+
},
35+
{
36+
name: 'expiresAt',
37+
type: 'timestamp',
38+
isNullable: false,
39+
},
40+
{
41+
name: 'used',
42+
type: 'boolean',
43+
default: false,
44+
isNullable: false,
45+
},
46+
{
47+
name: 'createdAt',
48+
type: 'timestamp',
49+
default: 'CURRENT_TIMESTAMP',
50+
isNullable: false,
51+
},
52+
],
53+
}),
54+
true,
55+
);
56+
57+
await queryRunner.createForeignKey(
58+
'password_resets',
59+
new TableForeignKey({
60+
columnNames: ['userId'],
61+
referencedColumnNames: ['id'],
62+
referencedTableName: 'user',
63+
onDelete: 'CASCADE',
64+
}),
65+
);
66+
67+
// Create index for faster lookups
68+
await queryRunner.query(
69+
`CREATE INDEX "IDX_password_resets_token" ON "password_resets" ("token")`,
70+
);
71+
}
72+
73+
public async down(queryRunner: QueryRunner): Promise<void> {
74+
const table = await queryRunner.getTable('password_resets');
75+
const foreignKey = table?.foreignKeys.find(
76+
(fk) => fk.columnNames.indexOf('userId') !== -1,
77+
);
78+
if (foreignKey) {
79+
await queryRunner.dropForeignKey('password_resets', foreignKey);
80+
}
81+
await queryRunner.dropTable('password_resets');
82+
}
83+
}

0 commit comments

Comments
 (0)