Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
6d5d6e9
feat(api): Introduce core Notification model and enums in Prisma schema
Oct 7, 2025
f47cc35
feat(docs): Document Prisma DTO generator annotations
Oct 7, 2025
924194b
refactor(api): Migrate AllocationNotificationService to unified Notif…
Oct 7, 2025
9db4e9f
feat(api): Integrate NotificationModule into AppModule
Oct 7, 2025
3f85c0a
feat(websocket): Enhance WebSocketService with on/off methods and gen…
Oct 7, 2025
bd490f1
feat(frontend): Implement unified NotificationService and UI bell icon
Oct 7, 2025
c85e598
feat(api): Enforce GM-only authorization for country access modificat…
Oct 7, 2025
1141e76
feat(e2e): Add GM-only authorization tests for country access API
Oct 7, 2025
695bcc8
feat(scoring): Add mission points calculation endpoint and schema fields
Oct 7, 2025
6829406
chore(config): Adjust API rate limiting for development and testing
Oct 7, 2025
eae9446
test(game): Add comprehensive cascade delete tests for GameService
Oct 7, 2025
7a1552f
feat(frontend): Add Allocation and ATO NGRX store and effects
Oct 7, 2025
1f935b2
refactor(frontend): Adjust game stats dashboard layout and MOB dashbo…
Oct 7, 2025
958b09b
chore(frontend): Temporarily disable and refactor allocation feature …
Oct 7, 2025
99b49ea
feat(game): Implement GameScoringService and E2E tests
Oct 7, 2025
c104edc
feat(notification): Implement unified notification system
Oct 7, 2025
2ee6592
test(cleanup): Add comprehensive unit tests for CleanupService
Oct 7, 2025
c525a67
Refactor: Compact JSON array formatting in mcp.json
Oct 7, 2025
a8dfd1c
Update: Context7 MCP server API key and formatting
Oct 7, 2025
cb27c5d
chore(deps): update prisma packages to version 6.17.0
yurisim Oct 7, 2025
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
27 changes: 7 additions & 20 deletions .roo/mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,13 @@
"mcpServers": {
"wallaby": {
"command": "powershell.exe",
"args": [
"node",
"$Env:USERPROFILE/.wallaby/mcp/"
],
"alwaysAllow": [
"wallaby_failingTests",
"wallaby_failingTestsForFile"
]
"args": ["node", "$Env:USERPROFILE/.wallaby/mcp/"],
"alwaysAllow": ["wallaby_failingTests", "wallaby_failingTestsForFile"]
},
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest"
],
"alwaysAllow": [
"browser_install"
],
"args": ["@playwright/mcp@latest"],
"alwaysAllow": ["browser_install"],
"disabled": false
},
"context7": {
Expand All @@ -27,12 +17,9 @@
"-y",
"@upstash/context7-mcp",
"--api-key",
"ctx7sk-7a021de2-dd21-42b8-88e1-06208aa3c848"
"ctx7sk-a254fd01-69cf-430d-b02a-12d2f6037dcb"
],
"alwaysAllow": [
"resolve-library-id",
"get-library-docs"
]
"alwaysAllow": ["resolve-library-id", "get-library-docs"]
}
}
}
}
Binary file modified .yarn/install-state.gz
Binary file not shown.
32 changes: 30 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,38 @@ npx nx lint pac-shield
- **Control Flow**: `@if/@for/@switch` only, no `*ngIf/*ngFor/*ngSwitch`
- **Imports**: Direct paths only, no barrel exports

### 🗃️ Database Schema
### 🗃️ Database Schema & DTO Generation
1. Edit `apps/pac-shield-api/src/prisma/schema.prisma`
2. Run `npx nx prisma-generate pac-shield-api`
3. Never edit `generated/` directories
3. **NEVER edit `generated/` directories** - changes will be overwritten

#### Prisma DTO Generator Annotations (brakebein/prisma-generator-nestjs-dto)

**Common Annotations:**
- `/// @DtoCreateOptional` - Include field in CreateDTO as optional
- `/// @DtoCreateRequired` - Include field in CreateDTO as required (for @default fields)
- `/// @DtoReadOnly` - Omit from Create/Update DTOs (auto-managed fields)
- `/// @DtoRelationIncludeId` - **CRITICAL**: Include relation's scalar ID field in DTOs
- **Must place on relation field, scalar ID must come AFTER relation in schema**
- Example:
```prisma
/// @DtoRelationIncludeId
game Game @relation(fields: [gameId], references: [id])
gameId Int // Must come AFTER the relation field
```

**When IDs aren't included:**
- Foreign key fields are excluded by default from generated DTOs
- Create custom request DTOs that extend generated DTOs (e.g., `CreateNotificationRequestDto`)
- Use Prisma's `connect` syntax in services:
```typescript
this.prisma.model.create({
data: {
...dtoData,
relation: { connect: { id: relationId } }
}
});
```

## Architecture Essentials
- **Dual Generation**: Backend DTOs + Frontend interfaces from same Prisma schema
Expand Down
131 changes: 129 additions & 2 deletions apps/pac-shield-api-e2e/src/pac-shield-api/country-access.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ describe('Country Access API Endpoints (Database → Local Storage)', () => {
let gmToken: string;
let gmApi: AxiosInstance;

let nonGmPlayerId: number;
let nonGmToken: string;
let nonGmApi: AxiosInstance;

let socket: Socket;

// Small utility to await a single socket event with timeout
Expand Down Expand Up @@ -45,14 +49,28 @@ describe('Country Access API Endpoints (Database → Local Storage)', () => {
gmToken = joinGmRes.data.token;
gmPlayerId = joinGmRes.data.id ?? joinGmRes.data.player?.id;

// 3) Authorized axios instance
// 3) Join as non-GM player
const joinPlayerRes = await axios.post(`/api/player/join`, {
roomCode,
playerName: 'Regular Player',
role: 'PLAYER',
});
expect(joinPlayerRes.data?.token).toBeDefined();
nonGmToken = joinPlayerRes.data.token;
nonGmPlayerId = joinPlayerRes.data.id ?? joinPlayerRes.data.player?.id;

// 4) Authorized axios instances
const baseURL = (axios.defaults.baseURL ?? 'http://localhost:3000').replace(/\/$/, '');
gmApi = axios.create({
baseURL,
headers: { Authorization: `Bearer ${gmToken}` },
});
nonGmApi = axios.create({
baseURL,
headers: { Authorization: `Bearer ${nonGmToken}` },
});

// 4) Start a Socket.IO client (default namespace) and join game room by roomCode
// 5) Start a Socket.IO client (default namespace) and join game room by roomCode
socket = io(baseURL, {
transports: ['websocket'],
forceNew: true,
Expand Down Expand Up @@ -439,4 +457,113 @@ describe('Country Access API Endpoints (Database → Local Storage)', () => {
expect(getRes2.data.countries.PHILIPPINES).toBe('NO_ACCESS'); // Should remain NO_ACCESS
});
});

describe('Authorization: GM-only access control', () => {
it('should allow GM to update country access', async () => {
const res = await gmApi.put(`/api/games/${gameId}/country-access`, {
changes: { JAPAN: true }
});
expect(res.status).toBe(200);
});

it('should deny non-GM from updating country access', async () => {
const p = nonGmApi.put(`/api/games/${gameId}/country-access`, {
changes: { JAPAN: true }
});
await expect(p).rejects.toMatchObject({
response: {
status: 403,
data: {
message: 'Only GMs can perform this action',
},
},
});
});

it('should allow GM to update dice roll for a country', async () => {
const res = await gmApi.put(`/api/games/${gameId}/country-access/JAPAN/dice-roll`, {
diceRoll: 10
});
expect(res.status).toBe(200);
});

it('should deny non-GM from updating dice roll for a country', async () => {
const p = nonGmApi.put(`/api/games/${gameId}/country-access/JAPAN/dice-roll`, {
diceRoll: 10
});
await expect(p).rejects.toMatchObject({
response: {
status: 403,
data: {
message: 'Only GMs can perform this action',
},
},
});
});

it('should allow GM to update bulk dice rolls', async () => {
const res = await gmApi.put(`/api/games/${gameId}/country-access/dice-rolls`, {
diceRolls: [
{ country: 'JAPAN' as Country, diceRoll: 10 }
]
});
expect(res.status).toBe(200);
});

it('should deny non-GM from updating bulk dice rolls', async () => {
const p = nonGmApi.put(`/api/games/${gameId}/country-access/dice-rolls`, {
diceRolls: [
{ country: 'JAPAN' as Country, diceRoll: 10 }
]
});
await expect(p).rejects.toMatchObject({
response: {
status: 403,
data: {
message: 'Only GMs can perform this action',
},
},
});
});

it('should allow GM to update bulk country access', async () => {
const res = await gmApi.put(`/api/games/${gameId}/country-access/bulk`, {
accessLevel: 'FULL_ACCESS' as AccessStatus
});
expect(res.status).toBe(200);
});

it('should deny non-GM from updating bulk country access', async () => {
const p = nonGmApi.put(`/api/games/${gameId}/country-access/bulk`, {
accessLevel: 'FULL_ACCESS' as AccessStatus
});
await expect(p).rejects.toMatchObject({
response: {
status: 403,
data: {
message: 'Only GMs can perform this action',
},
},
});
});

it('should deny unauthenticated requests to update country access', async () => {
const p = axios.put(`/api/games/${gameId}/country-access`, {
changes: { JAPAN: true }
});
await expect(p).rejects.toMatchObject({
response: {
status: 401,
},
});
});

it('should allow both GM and non-GM to read country access', async () => {
const gmRes = await gmApi.get(`/api/games/${gameId}/country-access`);
expect(gmRes.status).toBe(200);

const playerRes = await nonGmApi.get(`/api/games/${gameId}/country-access`);
expect(playerRes.status).toBe(200);
});
});
});
139 changes: 139 additions & 0 deletions apps/pac-shield-api-e2e/src/pac-shield-api/game-scoring.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import axios, { AxiosInstance } from 'axios';

describe('Game Scoring E2E (/game/:id/score)', () => {
let gameId: number;
let roomCode: string;

let commanderToken: string;
let commanderId: number;
let mobTeamId: number;

let authed: AxiosInstance;

// GM for GM-only actions (e.g., posting RFIs)
let gmToken: string;
let gmId: number;
let gmTeamId: number;
let gmAuthed: AxiosInstance;

beforeAll(async () => {
// Create a game
const create = await axios.post(`/api/game/create`, { victoryConditionMP: 100 });
expect([200, 201]).toContain(create.status);
gameId = create.data.id;
roomCode = create.data.roomCode;

// Join as a player
const join = await axios.post(`/api/player/join`, {
roomCode,
playerName: 'Scoring Commander',
});
expect([200, 201]).toContain(join.status);
commanderToken = join.data.token;
commanderId = join.data.id ?? join.data.player?.id;

// Choose a MOB team and join as COMMANDER
const gameSnap = await axios.get(`/api/game/${gameId}`);
const teams: Array<{ id: number; type: string }> = gameSnap.data?.teams ?? [];
const mobTeam = teams.find((t) => String(t.type).startsWith('MOB_')) ?? teams[0];
mobTeamId = mobTeam.id;

await axios.patch(`/api/player/${commanderId}`, { role: 'COMMANDER' });
await axios.post(`/api/player/${commanderId}/join-team`, { teamId: mobTeamId });

// Authorized axios for guarded FOS endpoints (Commander)
authed = axios.create({
baseURL: axios.defaults.baseURL,
headers: { Authorization: `Bearer ${commanderToken}` },
});

// Create and prepare a GM for GM-only actions (RFIs)
const joinGm = await axios.post(`/api/player/join`, {
roomCode,
playerName: 'Scoring GM',
});
expect([200, 201]).toContain(joinGm.status);
gmToken = joinGm.data.token;
gmId = joinGm.data.id ?? joinGm.data.player?.id;

await axios.patch(`/api/player/${gmId}`, { role: 'GM' });

const gameSnap2 = await axios.get(`/api/game/${gameId}`);
const teams2: Array<{ id: number; type: string }> = gameSnap2.data?.teams ?? [];
const gmTeam = teams2.find((t) => String(t.type) === 'GM');
expect(gmTeam).toBeDefined();
gmTeamId = gmTeam!.id;
await axios.post(`/api/player/${gmId}/join-team`, { teamId: gmTeamId });

gmAuthed = axios.create({
baseURL: axios.defaults.baseURL,
headers: { Authorization: `Bearer ${gmToken}` },
});
});

it('returns a zeroed score for a new game', async () => {
const scoreRes = await axios.get(`/api/game/${gameId}/score`);
expect(scoreRes.status).toBe(200);

const body = scoreRes.data;
expect(body).toHaveProperty('gameId', gameId);
expect(body).toHaveProperty('breakdown');
expect(body.breakdown.assessments.points).toBe(0);
expect(body.breakdown.crisisSorties.points).toBe(0);
expect(body.breakdown.destroyedTargets.points).toBe(0);
expect(body.breakdown.demoralizationPenalty.penalty).toBeGreaterThanOrEqual(0);
expect(typeof body.total).toBe('number');
});

it('awards +5 MP when a FOS has 10 RFIs answered (complete assessment)', async () => {
// Activate a FOS to create it
const fosDisplayNumber = 11;
const activate = await authed.post(`/api/fos/${fosDisplayNumber}/activate`, {
teamId: mobTeamId,
turnActivated: 1,
});
expect([200, 201]).toContain(activate.status);
const fosId: string = activate.data.id;
expect(typeof fosId).toBe('string');

// Answer 10 RFIs (any keys should be accepted by API; values coerced to strings)
const rfiKeys = [
'RFI1',
'RFI2',
'RFI3',
'RFI4',
'RFI5',
'RFI6',
'RFI7',
'RFI8',
'RFI9',
'RFI10',
];
for (const key of rfiKeys) {
const r = await gmAuthed.post(`/api/fos/${fosId}/rfi`, { rfiKey: key, rfiValue: 1 });
expect([200, 201]).toContain(r.status);
}

// Verify the answers are persisted
const answers = await authed.get(`/api/fos/${fosId}/rfi`);
expect(answers.status).toBe(200);
expect(Array.isArray(answers.data)).toBe(true);
// At least ten entries expected
expect(answers.data.length).toBeGreaterThanOrEqual(10);

// Score should reflect one fully assessed FOS (+5)
const scoreRes = await axios.get(`/api/game/${gameId}/score`);
expect(scoreRes.status).toBe(200);

const breakdown = scoreRes.data.breakdown;
expect(breakdown.assessments.count).toBeGreaterThanOrEqual(1);
expect(breakdown.assessments.points).toBeGreaterThanOrEqual(5);

// Ensure other buckets are not negatively impacting this scenario
expect(breakdown.crisisSorties.points).toBe(0);
expect(breakdown.destroyedTargets.points).toBe(0);

const expectedMinTotal = 5 - breakdown.demoralizationPenalty.penalty;
expect(scoreRes.data.total).toBeGreaterThanOrEqual(expectedMinTotal);
});
});
Loading