fix(ci): build shared packages before tests and fix formatting

- Add build:packages step to all test.yml jobs (fixes @manacore/shared-nestjs-auth not found)
- Handle missing coverage artifacts gracefully in test-coverage.yml
- Update .prettierignore to exclude apps-archived/ and problematic files
- Format all source files to pass CI checks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Wuesteon 2025-12-01 23:15:00 +01:00
parent 5282f5545b
commit 0ebfde0851
163 changed files with 15247 additions and 14677 deletions

View file

@ -9,6 +9,7 @@ Mana Core Auth is the central authentication service for the Mana Universe ecosy
### 1. ALWAYS USE BETTER AUTH - NO EXCEPTIONS
**DO NOT** implement custom authentication logic. Better Auth handles:
- User registration and sign-in
- JWT token generation (EdDSA algorithm)
- JWT token verification (via JWKS)
@ -19,13 +20,13 @@ Mana Core Auth is the central authentication service for the Mana Universe ecosy
### 2. JWT Rules
| DO | DON'T |
|----|-------|
| Use `jose` library for JWT operations | Use `jsonwebtoken` library |
| Use Better Auth's JWKS endpoint | Configure RSA keys in `.env` |
| Use EdDSA algorithm (Better Auth default) | Use RS256 or HS256 |
| Fetch JWKS from `/api/v1/auth/jwks` | Hardcode public keys |
| Keep JWT claims minimal | Add credit_balance, org data to JWT |
| DO | DON'T |
| ----------------------------------------- | ----------------------------------- |
| Use `jose` library for JWT operations | Use `jsonwebtoken` library |
| Use Better Auth's JWKS endpoint | Configure RSA keys in `.env` |
| Use EdDSA algorithm (Better Auth default) | Use RS256 or HS256 |
| Fetch JWKS from `/api/v1/auth/jwks` | Hardcode public keys |
| Keep JWT claims minimal | Add credit_balance, org data to JWT |
### 3. Before Making Auth Changes
@ -100,12 +101,12 @@ services/mana-core-auth/
## Key Files
| File | Purpose |
|------|---------|
| `src/auth/better-auth.config.ts` | Better Auth configuration with JWT + Org plugins |
| `src/auth/services/better-auth.service.ts` | Main auth service - ALL auth logic here |
| `src/db/schema/auth.schema.ts` | User, session, account, jwks tables |
| `docs/AUTHENTICATION_ARCHITECTURE.md` | Comprehensive auth documentation |
| File | Purpose |
| ------------------------------------------ | ------------------------------------------------ |
| `src/auth/better-auth.config.ts` | Better Auth configuration with JWT + Org plugins |
| `src/auth/services/better-auth.service.ts` | Main auth service - ALL auth logic here |
| `src/db/schema/auth.schema.ts` | User, session, account, jwks tables |
| `docs/AUTHENTICATION_ARCHITECTURE.md` | Comprehensive auth documentation |
## Environment Variables
@ -136,6 +137,7 @@ Other services call `POST /api/v1/auth/validate` with the JWT. The validation us
### Adding JWT claims
**DON'T** add dynamic data to JWT claims. Keep them minimal:
- `sub` (user ID)
- `email`
- `role`

View file

@ -18,10 +18,10 @@ src/db/schema/
## Commands
| Command | Description |
| --------------- | ------------------------------------- |
| `pnpm db:push` | Sync schema to database |
| `pnpm db:studio`| Open Drizzle Studio to view/edit data |
| Command | Description |
| ---------------- | ------------------------------------- |
| `pnpm db:push` | Sync schema to database |
| `pnpm db:studio` | Open Drizzle Studio to view/edit data |
## First-Time Setup

View file

@ -3,6 +3,7 @@
## Overview
The Mana Core authentication service uses PostgreSQL with two main schemas:
- `auth` - User authentication, sessions, and organization management
- `credits` - Credit system for B2C and B2B customers
@ -43,6 +44,7 @@ credits.organization_balances ←───────────────
### Core Tables
#### auth.organizations
Stores organization/company information for B2B customers.
```sql
@ -58,11 +60,13 @@ CREATE TABLE auth.organizations (
```
**Key Design Decisions:**
- Uses TEXT for IDs (Better Auth requirement - nanoid/ULID format)
- Slug is unique and URL-friendly for organization pages
- Metadata field allows flexible custom attributes
#### auth.members
Links users to organizations with roles (owner, admin, member).
```sql
@ -80,11 +84,13 @@ CREATE INDEX members_organization_user_idx ON auth.members(organization_id, user
```
**Key Design Decisions:**
- Composite index on (organization_id, user_id) for fast membership checks
- user_id is TEXT to match Better Auth expectations (actual data is UUID cast to TEXT)
- ON DELETE CASCADE ensures members are removed when org is deleted
#### auth.invitations
Tracks pending, accepted, and rejected organization invitations.
```sql
@ -105,6 +111,7 @@ CREATE INDEX invitations_status_idx ON auth.invitations(status);
```
**Key Design Decisions:**
- Index on email for quick lookup of pending invitations
- Index on status for filtering active invitations
- ON DELETE SET NULL for inviter (keeps history even if inviter deleted)
@ -113,6 +120,7 @@ CREATE INDEX invitations_status_idx ON auth.invitations(status);
## Organization Credit Management
### credits.organization_balances
Tracks credit pools for B2B organizations.
```sql
@ -130,6 +138,7 @@ CREATE TABLE credits.organization_balances (
```
**Key Design Decisions:**
- `balance`: Organization's total purchased credits
- `allocated_credits`: Sum of credits allocated to employees (not yet spent)
- `available_credits`: Credits owner can still allocate (calculated: balance - allocated_credits)
@ -138,12 +147,14 @@ CREATE TABLE credits.organization_balances (
- `version`: Enables optimistic locking to prevent race conditions
**Credit Flow:**
1. Owner purchases credits → `balance` increases
2. Owner allocates to employee → `allocated_credits` increases, `available_credits` decreases
3. Employee spends credits → employee's `credits.balances.balance` decreases
4. Owner deallocates from employee → `allocated_credits` decreases, `available_credits` increases
### credits.credit_allocations
Immutable audit trail of all credit allocations.
```sql
@ -167,6 +178,7 @@ CREATE INDEX credit_allocations_created_at_idx ON credits.credit_allocations(cre
```
**Key Design Decisions:**
- **Immutable**: No updates or deletes allowed (audit trail)
- `amount` can be positive (allocation) or negative (deallocation/adjustment)
- `balance_before`/`balance_after` track exact state changes
@ -174,6 +186,7 @@ CREATE INDEX credit_allocations_created_at_idx ON credits.credit_allocations(cre
- `reason` field for transparency and accountability
### credits.transactions (Updated)
Extended to support B2B transactions.
```sql
@ -185,6 +198,7 @@ CREATE INDEX transactions_organization_id_idx ON credits.transactions(organizati
```
**Key Design Decisions:**
- `organization_id` is **nullable** (NULL for B2C users, set for B2B employees)
- ON DELETE SET NULL preserves transaction history even if org deleted
- Enables organization-wide usage analytics and reporting
@ -194,6 +208,7 @@ CREATE INDEX transactions_organization_id_idx ON credits.transactions(organizati
### The UUID vs TEXT Challenge
**Problem:**
- Better Auth uses TEXT IDs (nanoid/ULID format like "abc123xyz")
- Our existing system uses UUID for user IDs
- PostgreSQL doesn't allow direct foreign keys between UUID and TEXT
@ -211,18 +226,19 @@ FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE;
```
**In Application Code:**
```typescript
// When inserting a member
await db.insert(members).values({
id: nanoid(),
organization_id: "org_abc123",
user_id: userId.toString(), // Convert UUID to TEXT
role: 'member'
id: nanoid(),
organization_id: 'org_abc123',
user_id: userId.toString(), // Convert UUID to TEXT
role: 'member',
});
// When querying
const member = await db.query.members.findFirst({
where: eq(members.userId, userId.toString())
where: eq(members.userId, userId.toString()),
});
```
@ -243,26 +259,31 @@ auth.is_organization_owner(org_id TEXT) → BOOLEAN
### Key Policies
**Organizations:**
- Members can view their organizations
- Any user can create organizations (Better Auth adds them as owner)
- Only owners can update/delete organizations
**Members:**
- Members can view other members in their orgs
- Owners/admins can add/remove/update members
- Members can remove themselves
**Invitations:**
- Members can view org invitations
- Invitees can view invitations sent to them
- Owners/admins can create/manage invitations
- Inviters and invitees can delete invitations
**Organization Balances:**
- Members can view org balance
- Only owners can modify balances
**Credit Allocations:**
- Employees can view allocations to them
- Owners/admins can view all org allocations
- Only owners can create allocations
@ -286,16 +307,19 @@ psql $DATABASE_URL -f src/db/migrations/0001_better_auth_organizations.sql
### Migration Files
**Up Migration:** `0001_better_auth_organizations.sql`
- Creates organization tables
- Creates credit management tables
- Adds foreign keys and indexes
- Sets up triggers
**Down Migration:** `0001_better_auth_organizations_down.sql`
- Reverses all changes
- Safe rollback path
**RLS Policies:** `postgres/init/03-organization-rls.sql`
- Applied automatically in Docker
- Can be run manually: `psql $DATABASE_URL -f postgres/init/03-organization-rls.sql`
@ -332,6 +356,7 @@ VALUES ('org_abc123');
### Indexes
All critical query paths are indexed:
- Organization lookups by slug
- Member lookups by user_id and organization_id
- Invitation lookups by email and status
@ -343,15 +368,18 @@ Both `credits.balances` and `credits.organization_balances` use a `version` colu
```typescript
// Prevent race conditions when allocating credits
await db.update(organizationBalances)
.set({
allocated_credits: sql`allocated_credits + ${amount}`,
version: sql`version + 1`
})
.where(and(
eq(organizationBalances.organizationId, orgId),
eq(organizationBalances.version, currentVersion)
));
await db
.update(organizationBalances)
.set({
allocated_credits: sql`allocated_credits + ${amount}`,
version: sql`version + 1`,
})
.where(
and(
eq(organizationBalances.organizationId, orgId),
eq(organizationBalances.version, currentVersion)
)
);
```
## Schema Relationships
@ -399,12 +427,14 @@ ADD COLUMN credits_expire_at TIMESTAMPTZ;
### Common Issues
**Foreign Key Errors (UUID vs TEXT):**
```sql
-- Check if casting is needed
SELECT user_id::uuid FROM auth.members WHERE user_id ~ '^[0-9a-f-]{36}$';
```
**RLS Policy Blocking Queries:**
```sql
-- Temporarily disable RLS for debugging (development only!)
ALTER TABLE auth.organizations DISABLE ROW LEVEL SECURITY;
@ -414,17 +444,18 @@ SELECT * FROM pg_policies WHERE tablename = 'organizations';
```
**Optimistic Lock Failures:**
```typescript
// Retry logic for version conflicts
const maxRetries = 3;
for (let i = 0; i < maxRetries; i++) {
try {
await allocateCredits(orgId, employeeId, amount);
break;
} catch (err) {
if (i === maxRetries - 1) throw err;
await sleep(100 * Math.pow(2, i)); // Exponential backoff
}
try {
await allocateCredits(orgId, employeeId, amount);
break;
} catch (err) {
if (i === maxRetries - 1) throw err;
await sleep(100 * Math.pow(2, i)); // Exponential backoff
}
}
```

View file

@ -19,9 +19,7 @@ module.exports = {
coverageDirectory: '../coverage',
testEnvironment: 'node',
// Handle ESM modules (nanoid, better-auth)
transformIgnorePatterns: [
'node_modules/(?!(nanoid|better-auth)/)',
],
transformIgnorePatterns: ['node_modules/(?!(nanoid|better-auth)/)'],
moduleNameMapper: {
'^src/(.*)$': '<rootDir>/$1',
'^nanoid$': '<rootDir>/../test/__mocks__/nanoid.ts',

View file

@ -303,7 +303,12 @@ export const mockMemberFactory = {
* Mock Credit Allocation Factory
*/
export const mockCreditAllocationFactory = {
create: (organizationId: string, employeeId: string, allocatedBy: string, overrides: Partial<any> = {}) => ({
create: (
organizationId: string,
employeeId: string,
allocatedBy: string,
overrides: Partial<any> = {}
) => ({
id: nanoid(),
organizationId,
employeeId,

View file

@ -103,8 +103,7 @@ export const assertHelpers = {
* Assert that a value is a valid UUID
*/
assertIsUuid: (value: string) => {
const uuidRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
expect(value).toMatch(uuidRegex);
},

View file

@ -74,8 +74,7 @@ Antworte NUR mit validem JSON in diesem Format (keine Markdown-Codeblocks, kein
private fallbackAnalysis(feedbackText: string): FeedbackAnalysis {
// Simple fallback: use first 60 chars as title, default category
const title =
feedbackText.length > 60 ? feedbackText.substring(0, 57) + '...' : feedbackText;
const title = feedbackText.length > 60 ? feedbackText.substring(0, 57) + '...' : feedbackText;
// Simple keyword-based category detection
const lowerText = feedbackText.toLowerCase();
@ -88,7 +87,11 @@ Antworte NUR mit validem JSON in diesem Format (keine Markdown-Codeblocks, kein
lowerText.includes('funktioniert nicht')
) {
category = 'bug';
} else if (lowerText.includes('?') || lowerText.includes('frage') || lowerText.includes('wie')) {
} else if (
lowerText.includes('?') ||
lowerText.includes('frage') ||
lowerText.includes('wie')
) {
category = 'question';
} else if (
lowerText.includes('besser') ||

View file

@ -341,7 +341,10 @@ describe('AuthController', () => {
it('should return invalid for expired token', async () => {
const body = { token: 'expired-token' };
betterAuthService.validateToken.mockResolvedValue({ valid: false, error: 'Token expired' } as any);
betterAuthService.validateToken.mockResolvedValue({
valid: false,
error: 'Token expired',
} as any);
const result = await controller.validate(body);
@ -483,7 +486,11 @@ describe('AuthController', () => {
describe('POST /auth/organizations/:id/invite', () => {
it('should invite an employee to organization', async () => {
const orgId = 'org-123';
const inviteDto = { organizationId: orgId, employeeEmail: 'employee@acme.com', role: 'member' as const };
const inviteDto = {
organizationId: orgId,
employeeEmail: 'employee@acme.com',
role: 'member' as const,
};
const expectedResult = {
id: 'invitation-123',
@ -508,7 +515,11 @@ describe('AuthController', () => {
it('should throw ForbiddenException when inviter lacks permission', async () => {
const orgId = 'org-123';
const inviteDto = { organizationId: orgId, employeeEmail: 'employee@acme.com', role: 'member' as const };
const inviteDto = {
organizationId: orgId,
employeeEmail: 'employee@acme.com',
role: 'member' as const,
};
betterAuthService.inviteEmployee.mockRejectedValue(
new ForbiddenException('You do not have permission to invite members')
@ -585,9 +596,9 @@ describe('AuthController', () => {
new ForbiddenException('You do not have permission to remove members')
);
await expect(controller.removeMember('org-123', 'member-456', mockAuthHeader)).rejects.toThrow(
ForbiddenException
);
await expect(
controller.removeMember('org-123', 'member-456', mockAuthHeader)
).rejects.toThrow(ForbiddenException);
});
});

View file

@ -19,18 +19,8 @@ import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { jwt } from 'better-auth/plugins/jwt';
import { organization } from 'better-auth/plugins/organization';
import { getDb } from '../db/connection';
import {
organizations,
members,
invitations,
} from '../db/schema/organizations.schema';
import {
users,
sessions,
accounts,
verificationTokens,
jwks,
} from '../db/schema/auth.schema';
import { organizations, members, invitations } from '../db/schema/organizations.schema';
import { users, sessions, accounts, verificationTokens, jwks } from '../db/schema/auth.schema';
import type { JWTPayloadContext } from './types/better-auth.types';
/**

View file

@ -287,9 +287,9 @@ describe('JWT Token Validation (Minimal Claims)', () => {
// Tamper with the token - try to change role to admin
const parts = token.split('.');
const tamperedPayload = Buffer.from(
JSON.stringify({ ...payload, role: 'admin' })
).toString('base64url');
const tamperedPayload = Buffer.from(JSON.stringify({ ...payload, role: 'admin' })).toString(
'base64url'
);
const tamperedToken = `${parts[0]}.${tamperedPayload}.${parts[2]}`;
expect(() => {

View file

@ -11,11 +11,7 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import {
ConflictException,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
import { ConflictException, NotFoundException, ForbiddenException } from '@nestjs/common';
import { BetterAuthService } from './better-auth.service';
import { createMockConfigService } from '../../__tests__/utils/test-helpers';
@ -156,9 +152,7 @@ describe('BetterAuthService', () => {
};
// Mock Better Auth error for existing user
mockAuthApi.signUpEmail.mockRejectedValue(
new Error('User with this email already exists')
);
mockAuthApi.signUpEmail.mockRejectedValue(new Error('User with this email already exists'));
await expect(service.registerB2C(registerDto)).rejects.toThrow(ConflictException);
await expect(service.registerB2C(registerDto)).rejects.toThrow(
@ -365,9 +359,7 @@ describe('BetterAuthService', () => {
});
// Mock organization creation failure
mockAuthApi.createOrganization.mockRejectedValue(
new Error('Failed to create organization')
);
mockAuthApi.createOrganization.mockRejectedValue(new Error('Failed to create organization'));
await expect(service.registerB2B(registerDto)).rejects.toThrow(
'Failed to create organization'
@ -411,9 +403,7 @@ describe('BetterAuthService', () => {
organizationName: 'Acme Corporation',
};
mockAuthApi.signUpEmail.mockRejectedValue(
new Error('User with this email already exists')
);
mockAuthApi.signUpEmail.mockRejectedValue(new Error('User with this email already exists'));
await expect(service.registerB2B(registerDto)).rejects.toThrow(ConflictException);
await expect(service.registerB2B(registerDto)).rejects.toThrow('Owner email already exists');
@ -526,13 +516,9 @@ describe('BetterAuthService', () => {
inviterToken: 'inviter-token',
};
mockAuthApi.inviteMember.mockRejectedValue(
new Error('User is already a member')
);
mockAuthApi.inviteMember.mockRejectedValue(new Error('User is already a member'));
await expect(service.inviteEmployee(inviteDto)).rejects.toThrow(
'User is already a member'
);
await expect(service.inviteEmployee(inviteDto)).rejects.toThrow('User is already a member');
});
it('should throw ForbiddenException if inviter lacks permission', async () => {
@ -588,9 +574,7 @@ describe('BetterAuthService', () => {
userToken: 'user-token',
};
mockAuthApi.acceptInvitation.mockRejectedValue(
new Error('Invitation expired')
);
mockAuthApi.acceptInvitation.mockRejectedValue(new Error('Invitation expired'));
await expect(service.acceptInvitation(acceptDto)).rejects.toThrow(NotFoundException);
await expect(service.acceptInvitation(acceptDto)).rejects.toThrow(
@ -604,9 +588,7 @@ describe('BetterAuthService', () => {
userToken: 'user-token',
};
mockAuthApi.acceptInvitation.mockRejectedValue(
new Error('Invitation not found')
);
mockAuthApi.acceptInvitation.mockRejectedValue(new Error('Invitation not found'));
await expect(service.acceptInvitation(acceptDto)).rejects.toThrow(NotFoundException);
});
@ -652,9 +634,7 @@ describe('BetterAuthService', () => {
});
it('should return empty array on error', async () => {
mockAuthApi.getFullOrganization.mockRejectedValue(
new Error('Database error')
);
mockAuthApi.getFullOrganization.mockRejectedValue(new Error('Database error'));
const result = await service.getOrganizationMembers('org-123');
@ -698,13 +678,9 @@ describe('BetterAuthService', () => {
removerToken: 'admin-token',
};
mockAuthApi.removeMember.mockRejectedValue(
new Error('Member not found')
);
mockAuthApi.removeMember.mockRejectedValue(new Error('Member not found'));
await expect(service.removeMember(removeDto)).rejects.toThrow(
'Member not found'
);
await expect(service.removeMember(removeDto)).rejects.toThrow('Member not found');
});
it('should throw ForbiddenException if remover lacks permission', async () => {
@ -782,9 +758,7 @@ describe('BetterAuthService', () => {
new Error('Organization not found or you are not a member')
);
await expect(service.setActiveOrganization(setActiveDto)).rejects.toThrow(
NotFoundException
);
await expect(service.setActiveOrganization(setActiveDto)).rejects.toThrow(NotFoundException);
await expect(service.setActiveOrganization(setActiveDto)).rejects.toThrow(
'Organization not found or you are not a member'
);
@ -970,13 +944,9 @@ describe('BetterAuthService', () => {
name: 'Test User',
};
mockAuthApi.signUpEmail.mockRejectedValue(
new Error('Unexpected server error')
);
mockAuthApi.signUpEmail.mockRejectedValue(new Error('Unexpected server error'));
await expect(service.registerB2C(registerDto)).rejects.toThrow(
'Unexpected server error'
);
await expect(service.registerB2C(registerDto)).rejects.toThrow('Unexpected server error');
});
it('should propagate network errors', async () => {
@ -987,13 +957,9 @@ describe('BetterAuthService', () => {
inviterToken: 'token',
};
mockAuthApi.inviteMember.mockRejectedValue(
new Error('Network timeout')
);
mockAuthApi.inviteMember.mockRejectedValue(new Error('Network timeout'));
await expect(service.inviteEmployee(inviteDto)).rejects.toThrow(
'Network timeout'
);
await expect(service.inviteEmployee(inviteDto)).rejects.toThrow('Network timeout');
});
});
});

View file

@ -24,13 +24,7 @@ import { ConfigService } from '@nestjs/config';
import { createBetterAuth, type BetterAuthInstance } from '../better-auth.config';
import { getDb } from '../../db/connection';
import { balances, organizationBalances } from '../../db/schema/credits.schema';
import {
hasUser,
hasToken,
hasMember,
hasMembers,
hasSession,
} from '../types/better-auth.types';
import { hasUser, hasToken, hasMember, hasMembers, hasSession } from '../types/better-auth.types';
import type {
RegisterB2CDto,
RegisterB2BDto,

View file

@ -360,9 +360,7 @@ describe('CreditsService', () => {
return callback(txMock);
});
await expect(service.useCredits(userId, useCreditsDto)).rejects.toThrow(
BadRequestException
);
await expect(service.useCredits(userId, useCreditsDto)).rejects.toThrow(BadRequestException);
await expect(service.useCredits(userId, useCreditsDto)).rejects.toThrow(
'Insufficient credits'
);
@ -387,9 +385,7 @@ describe('CreditsService', () => {
return callback(txMock);
});
await expect(service.useCredits(userId, useCreditsDto)).rejects.toThrow(
NotFoundException
);
await expect(service.useCredits(userId, useCreditsDto)).rejects.toThrow(NotFoundException);
await expect(service.useCredits(userId, useCreditsDto)).rejects.toThrow(
'User balance not found'
);
@ -436,9 +432,7 @@ describe('CreditsService', () => {
values: jest.fn().mockReturnThis(),
};
txMock.returning.mockResolvedValue([
mockTransactionFactory.create(userId),
]);
txMock.returning.mockResolvedValue([mockTransactionFactory.create(userId)]);
return callback(txMock);
});
@ -477,9 +471,7 @@ describe('CreditsService', () => {
return callback(txMock);
});
await expect(service.useCredits(userId, useCreditsDto)).rejects.toThrow(
ConflictException
);
await expect(service.useCredits(userId, useCreditsDto)).rejects.toThrow(ConflictException);
await expect(service.useCredits(userId, useCreditsDto)).rejects.toThrow(
'Balance was modified by another transaction'
);
@ -551,9 +543,7 @@ describe('CreditsService', () => {
}),
};
txMock.returning.mockResolvedValue([
mockTransactionFactory.create(userId),
]);
txMock.returning.mockResolvedValue([mockTransactionFactory.create(userId)]);
return callback(txMock);
});
@ -615,9 +605,7 @@ describe('CreditsService', () => {
}),
};
txMock.returning.mockResolvedValue([
mockTransactionFactory.create(userId),
]);
txMock.returning.mockResolvedValue([mockTransactionFactory.create(userId)]);
return callback(txMock);
});
@ -795,12 +783,7 @@ describe('CreditsService', () => {
lastDailyResetAt: lastMonth,
});
mockDb.mockResults(
[mockBalance],
[],
[],
[{ ...mockBalance, freeCreditsRemaining: 55 }]
);
mockDb.mockResults([mockBalance], [], [], [{ ...mockBalance, freeCreditsRemaining: 55 }]);
await service.getBalance(userId);
@ -863,9 +846,7 @@ describe('CreditsService', () => {
values: jest.fn().mockReturnThis(),
};
txMock.returning.mockResolvedValue([
mockTransactionFactory.create(userId, { amount: 0 }),
]);
txMock.returning.mockResolvedValue([mockTransactionFactory.create(userId, { amount: 0 })]);
return callback(txMock);
});
@ -908,9 +889,7 @@ describe('CreditsService', () => {
values: jest.fn().mockReturnThis(),
};
txMock.returning.mockResolvedValue([
mockTransactionFactory.create(userId),
]);
txMock.returning.mockResolvedValue([mockTransactionFactory.create(userId)]);
return callback(txMock);
});
@ -1126,9 +1105,9 @@ describe('CreditsService', () => {
return callback(txMock);
});
await expect(
service.allocateCredits(allocatorUserId, allocateDto)
).rejects.toThrow(ForbiddenException);
await expect(service.allocateCredits(allocatorUserId, allocateDto)).rejects.toThrow(
ForbiddenException
);
});
it('should throw BadRequestException if org has insufficient available credits', async () => {
@ -1167,9 +1146,9 @@ describe('CreditsService', () => {
return callback(txMock);
});
await expect(
service.allocateCredits(allocatorUserId, allocateDto)
).rejects.toThrow(BadRequestException);
await expect(service.allocateCredits(allocatorUserId, allocateDto)).rejects.toThrow(
BadRequestException
);
});
it('should auto-create employee balance if it does not exist', async () => {
@ -1530,12 +1509,12 @@ describe('CreditsService', () => {
return callback(txMock);
});
await expect(
service.allocateCredits(allocatorUserId, allocateDto)
).rejects.toThrow(ConflictException);
await expect(
service.allocateCredits(allocatorUserId, allocateDto)
).rejects.toThrow('Organization balance was modified by another transaction');
await expect(service.allocateCredits(allocatorUserId, allocateDto)).rejects.toThrow(
ConflictException
);
await expect(service.allocateCredits(allocatorUserId, allocateDto)).rejects.toThrow(
'Organization balance was modified by another transaction'
);
});
});
@ -1671,12 +1650,12 @@ describe('CreditsService', () => {
// Mock: No org balance found - .limit(1) returns empty
mockDb.limit.mockResolvedValueOnce([]);
await expect(
service.getOrganizationBalance(organizationId)
).rejects.toThrow(NotFoundException);
await expect(
service.getOrganizationBalance(organizationId)
).rejects.toThrow('Organization balance not found');
await expect(service.getOrganizationBalance(organizationId)).rejects.toThrow(
NotFoundException
);
await expect(service.getOrganizationBalance(organizationId)).rejects.toThrow(
'Organization balance not found'
);
});
});
@ -1716,9 +1695,7 @@ describe('CreditsService', () => {
}),
};
txMock.returning.mockResolvedValue([
mockTransactionFactory.create(userId),
]);
txMock.returning.mockResolvedValue([mockTransactionFactory.create(userId)]);
return callback(txMock);
});
@ -1769,9 +1746,7 @@ describe('CreditsService', () => {
}),
};
txMock.returning.mockResolvedValue([
mockTransactionFactory.create(userId),
]);
txMock.returning.mockResolvedValue([mockTransactionFactory.create(userId)]);
return callback(txMock);
});
@ -1843,12 +1818,12 @@ describe('CreditsService', () => {
return callback(txMock);
});
await expect(
service.deductCredits(userId, useCreditsDto, organizationId)
).rejects.toThrow(ConflictException);
await expect(
service.deductCredits(userId, useCreditsDto, organizationId)
).rejects.toThrow('Balance was modified by another transaction');
await expect(service.deductCredits(userId, useCreditsDto, organizationId)).rejects.toThrow(
ConflictException
);
await expect(service.deductCredits(userId, useCreditsDto, organizationId)).rejects.toThrow(
'Balance was modified by another transaction'
);
});
it('should handle insufficient credits error', async () => {
@ -1876,12 +1851,12 @@ describe('CreditsService', () => {
return callback(txMock);
});
await expect(
service.deductCredits(userId, useCreditsDto, organizationId)
).rejects.toThrow(BadRequestException);
await expect(
service.deductCredits(userId, useCreditsDto, organizationId)
).rejects.toThrow('Insufficient credits');
await expect(service.deductCredits(userId, useCreditsDto, organizationId)).rejects.toThrow(
BadRequestException
);
await expect(service.deductCredits(userId, useCreditsDto, organizationId)).rejects.toThrow(
'Insufficient credits'
);
});
});
});

View file

@ -341,18 +341,11 @@ export class CreditsService {
const [member] = await tx
.select()
.from(members)
.where(
and(
eq(members.organizationId, organizationId),
eq(members.userId, allocatorUserId)
)
)
.where(and(eq(members.organizationId, organizationId), eq(members.userId, allocatorUserId)))
.limit(1);
if (!member || member.role !== 'owner') {
throw new ForbiddenException(
'Only organization owners can allocate credits'
);
throw new ForbiddenException('Only organization owners can allocate credits');
}
// 2. Get organization balance with row lock
@ -441,12 +434,7 @@ export class CreditsService {
version: employeeBalance.version + 1,
updatedAt: new Date(),
})
.where(
and(
eq(balances.userId, employeeId),
eq(balances.version, employeeBalance.version)
)
)
.where(and(eq(balances.userId, employeeId), eq(balances.version, employeeBalance.version)))
.returning();
if (updateEmployeeResult.length === 0) {
@ -506,11 +494,7 @@ export class CreditsService {
const db = this.getDb();
// Get employee's personal balance
const [balance] = await db
.select()
.from(balances)
.where(eq(balances.userId, userId))
.limit(1);
const [balance] = await db.select().from(balances).where(eq(balances.userId, userId)).limit(1);
if (!balance) {
return null;
@ -571,11 +555,7 @@ export class CreditsService {
* Deduct credits with organization tracking
* Enhanced version of useCredits that tracks organization_id for B2B users
*/
async deductCredits(
userId: string,
useCreditsDto: UseCreditsDto,
organizationId?: string
) {
async deductCredits(userId: string, useCreditsDto: UseCreditsDto, organizationId?: string) {
const db = this.getDb();
// Check for idempotency

View file

@ -1,4 +1,13 @@
import { pgSchema, uuid, text, timestamp, boolean, jsonb, pgEnum, index } from 'drizzle-orm/pg-core';
import {
pgSchema,
uuid,
text,
timestamp,
boolean,
jsonb,
pgEnum,
index,
} from 'drizzle-orm/pg-core';
export const authSchema = pgSchema('auth');

View file

@ -41,10 +41,7 @@ export class FeedbackController {
@Get('my')
@UseGuards(JwtAuthGuard)
async getMyFeedback(
@CurrentUser() user: CurrentUserData,
@Query('appId') appId?: string
) {
async getMyFeedback(@CurrentUser() user: CurrentUserData, @Query('appId') appId?: string) {
return this.feedbackService.getMyFeedback(user.userId, appId);
}

View file

@ -105,7 +105,7 @@ export class FeedbackService {
sql`${feedbackVotes.feedbackId} = ANY(${feedbackIds})`
)
)
: [];
: [];
votedFeedbackIds = new Set(userVotes.map((v) => v.feedbackId));
}
@ -144,7 +144,7 @@ export class FeedbackService {
sql`${feedbackVotes.feedbackId} = ANY(${feedbackIds})`
)
)
: [];
: [];
const votedFeedbackIds = new Set(userVotes.map((v) => v.feedbackId));

View file

@ -26,7 +26,9 @@ import { randomBytes } from 'crypto';
// Helper to generate random IDs (avoiding nanoid ESM issues in Jest)
const generateId = (length: number = 16): string => {
return randomBytes(Math.ceil(length / 2)).toString('hex').slice(0, length);
return randomBytes(Math.ceil(length / 2))
.toString('hex')
.slice(0, length);
};
describe('B2B Organization Journey (E2E)', () => {

View file

@ -486,19 +486,13 @@ describe('B2C User Journey (E2E)', () => {
});
it('should reject SQL injection attempts in email field', async () => {
const sqlInjectionPayloads = [
"admin'--",
"' OR '1'='1",
"'; DROP TABLE users; --",
];
const sqlInjectionPayloads = ["admin'--", "' OR '1'='1", "'; DROP TABLE users; --"];
for (const payload of sqlInjectionPayloads) {
const response = await request(app.getHttpServer())
.post('/auth/login')
.send({
email: payload,
password: 'SomePassword123!',
});
const response = await request(app.getHttpServer()).post('/auth/login').send({
email: payload,
password: 'SomePassword123!',
});
// Should fail safely without SQL injection
expect([400, 401]).toContain(response.status);

View file

@ -217,9 +217,7 @@ describe('Authentication Flow Integration Tests', () => {
await authService.logout(sessionId);
// Attempt to refresh with revoked token
await expect(authService.refreshToken(refreshToken)).rejects.toThrow(
'Invalid refresh token'
);
await expect(authService.refreshToken(refreshToken)).rejects.toThrow('Invalid refresh token');
});
});

View file

@ -165,9 +165,7 @@ describe('Credit Flow Integration Tests', () => {
// Balance should be unchanged
const balanceAfterSecond = await creditsService.getBalance(userId);
expect(balanceAfterSecond.freeCreditsRemaining).toBe(
balanceAfterFirst.freeCreditsRemaining
);
expect(balanceAfterSecond.freeCreditsRemaining).toBe(balanceAfterFirst.freeCreditsRemaining);
expect(balanceAfterSecond.totalSpent).toBe(balanceAfterFirst.totalSpent);
});
@ -447,9 +445,7 @@ describe('Credit Flow Integration Tests', () => {
// Balance should be unchanged
const balanceAfterError = await creditsService.getBalance(userId);
expect(balanceAfterError.freeCreditsRemaining).toBe(
initialBalance.freeCreditsRemaining
);
expect(balanceAfterError.freeCreditsRemaining).toBe(initialBalance.freeCreditsRemaining);
expect(balanceAfterError.balance).toBe(initialBalance.balance);
expect(balanceAfterError.totalSpent).toBe(initialBalance.totalSpent);
});

View file

@ -4,16 +4,17 @@
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": ["ts-jest", {
"tsconfig": {
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
"^.+\\.(t|j)s$": [
"ts-jest",
{
"tsconfig": {
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
}
}
}]
]
},
"transformIgnorePatterns": [
"node_modules/(?!(nanoid|better-auth)/)"
],
"transformIgnorePatterns": ["node_modules/(?!(nanoid|better-auth)/)"],
"moduleNameMapper": {
"^nanoid$": "<rootDir>/__mocks__/nanoid.ts",
"^better-auth$": "<rootDir>/__mocks__/better-auth.ts",

View file

@ -12,7 +12,10 @@ jest.setTimeout(30000);
* Generate random ID using crypto
*/
const generateRandomId = (length: number = 10): string => {
return crypto.randomBytes(Math.ceil(length / 2)).toString('hex').slice(0, length);
return crypto
.randomBytes(Math.ceil(length / 2))
.toString('hex')
.slice(0, length);
};
/**