Skip to content
Open
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
5 changes: 3 additions & 2 deletions project/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"zod": "^4.0.2",
"zustand": "^5.0.6"
"zustand": "^5.0.6",
"@prisma/adapter-libsql": "^6.11.1"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
Expand All @@ -72,4 +73,4 @@
"typescript": "^5",
"vitest": "^4.1.8"
}
}
}
1 change: 0 additions & 1 deletion project/src/components/amph/str-data-grid.tsx
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,6 @@ export function StrDataGrid() {
[thresholds.acosTarget, thresholds.roasMinimum]
);

// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data: searchTerms,
columns,
Expand Down
54 changes: 34 additions & 20 deletions project/src/lib/db.ts
100755 → 100644
Original file line number Diff line number Diff line change
@@ -1,26 +1,40 @@
import { PrismaClient } from '@prisma/client'
import { PrismaLibSQL } from '@prisma/adapter-libsql';
import { PrismaClient } from '@prisma/client';

const databaseUrl = process.env.DATABASE_URL;
const databaseAuthToken = process.env.DATABASE_AUTH_TOKEN;

if (process.env.NODE_ENV === 'production' && !databaseUrl) {
throw new Error('Production startup aborted: DATABASE_URL is not defined');
}

const resolvedDatabaseUrl = databaseUrl ?? 'file:./prisma/dev.db';

const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
prisma: PrismaClient | undefined;
};

function createPrismaClient(): PrismaClient {
const adapter = new PrismaLibSQL({
url: resolvedDatabaseUrl,
...(databaseAuthToken ? { authToken: databaseAuthToken } : {}),
});

return new PrismaClient({
adapter,
log:
process.env.NODE_ENV === 'development'
? ['query', 'error', 'warn']
: ['error'],
});
}

export const db = globalForPrisma.prisma ?? createPrismaClient();

if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = db;
}

// Lazily create PrismaClient — this defers connection errors to query time
// so the app can start even if DATABASE_URL is temporarily unavailable
export const db =
globalForPrisma.prisma ??
new PrismaClient({
// Only log queries in development — avoid leaking query details and
// performance overhead in production
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
// Add connection timeout and retry settings for production resilience
datasources: process.env.DATABASE_URL
? undefined
: undefined,
})

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db

// Helper to check if database is configured
export function isDatabaseConfigured(): boolean {
return Boolean(process.env.DATABASE_URL)
return typeof databaseUrl === 'string' && databaseUrl.length > 0;
}
28 changes: 18 additions & 10 deletions project/src/lib/env.ts
100755 → 100644
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
/**
* ProjectAmazonPH Academy: Environment validation — ponytail: Zod schema, ~15 lines.
*/

import { z } from 'zod';

const envSchema = z.object({
DATABASE_URL: z.string().min(1),
NEXTAUTH_SECRET: z.string().min(32, 'NEXTAUTH_SECRET must be >= 32 chars'),
DATABASE_AUTH_TOKEN: z.string().min(1).optional(),
NEXTAUTH_SECRET: z.string().min(32, 'NEXTAUTH_SECRET must be at least 32 characters'),
NEXTAUTH_URL: z.string().url().optional(),
NEXT_PUBLIC_SITE_URL: z.string().url(),
DEMO_MODE: z.enum(['true', 'false']).optional(),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
});

export function validateEnv() {
export type AppEnv = z.infer<typeof envSchema>;

export function validateEnv(): AppEnv | null {
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
const msgs = parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ');
console.warn(`[WARN] Env validation: ${msgs}`);
if (parsed.success) return parsed.data;

const message = parsed.error.issues
.map((issue) => `${issue.path.join('.')}: ${issue.message}`)
.join('; ');

if (process.env.NODE_ENV === 'production') {
throw new Error(`Invalid production environment: ${message}`);
}
return parsed;

console.warn(`[WARN] Environment validation failed: ${message}`);
return null;
}
87 changes: 87 additions & 0 deletions project/src/lib/xp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { Prisma } from '@prisma/client';
import { beforeEach, describe, expect, it, vi } from 'vitest';

const mocks = vi.hoisted(() => ({
ledgerCreate: vi.fn(),
userUpdate: vi.fn(),
transaction: vi.fn(),
}));

vi.mock('@/lib/db', () => ({
db: {
$transaction: mocks.transaction,
},
}));

import { awardXp } from '@/lib/xp';

describe('awardXp', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.transaction.mockImplementation(async (callback) =>
callback({
xpLedger: { create: mocks.ledgerCreate },
user: { update: mocks.userUpdate },
})
);
mocks.ledgerCreate.mockResolvedValue({ id: 'ledger-1' });
mocks.userUpdate
.mockResolvedValueOnce({ xp: 750 })
.mockResolvedValueOnce({ xp: 750 });
});

it('creates one ledger entry and updates the cached XP projection', async () => {
const granted = await awardXp(
'user-1',
'SIMULATION',
'attempt-1',
200,
{ simulationType: 'BID_ELEVATOR' }
);

expect(granted).toBe(true);
expect(mocks.ledgerCreate).toHaveBeenCalledOnce();
expect(mocks.userUpdate).toHaveBeenCalledTimes(2);
expect(mocks.userUpdate).toHaveBeenNthCalledWith(1, {
where: { id: 'user-1' },
data: {
xp: { increment: 200 },
lastActiveAt: expect.any(Date),
},
select: { xp: true },
});
expect(mocks.userUpdate).toHaveBeenNthCalledWith(2, {
where: { id: 'user-1' },
data: { level: 2 },
});
});

it('does not update cached XP when the source was already awarded', async () => {
mocks.ledgerCreate.mockRejectedValue(
new Prisma.PrismaClientKnownRequestError('Unique constraint failed', {
code: 'P2002',
clientVersion: '6.19.3',
})
);

const granted = await awardXp(
'user-1',
'SIMULATION',
'attempt-1',
200
);

expect(granted).toBe(false);
expect(mocks.userUpdate).not.toHaveBeenCalled();
});

it('rejects invalid awards before opening a transaction', async () => {
await expect(awardXp('', 'SIMULATION', 'attempt-1', 200)).resolves.toBe(false);
await expect(awardXp('user-1', 'SIMULATION', 'attempt-1', 0)).resolves.toBe(false);
await expect(
awardXp('user-1', 'SIMULATION', 'attempt-1', Number.NaN)
).resolves.toBe(false);

expect(mocks.transaction).not.toHaveBeenCalled();
});
});
97 changes: 97 additions & 0 deletions project/src/lib/xp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { Prisma } from '@prisma/client';
import { db } from '@/lib/db';

const MAX_XP_AWARD = 1_000_000;
const MAX_METADATA_BYTES = 8_192;

export interface AwardXpInput {
userId: string;
sourceType: string;
sourceId: string;
amount: number;
metadata?: unknown;
}

/**
* Grants XP exactly once for a deterministic source key.
*
* The ledger insert and cached user projection update happen in one database
* transaction. If the unique ledger constraint is hit, the transaction rolls
* back and the user's XP is left unchanged.
*
* @returns true when XP was granted, false when the award was invalid or had
* already been granted.
*/
export async function awardXp(
userId: string,
sourceType: string,
sourceId: string,
amount: number,
metadata?: unknown
): Promise<boolean> {
const input: AwardXpInput = {
userId: userId.trim(),
sourceType: sourceType.trim(),
sourceId: sourceId.trim(),
amount,
metadata,
};

if (!input.userId || !input.sourceType || !input.sourceId) return false;
if (!Number.isFinite(input.amount) || input.amount <= 0) return false;

const xp = Math.min(Math.floor(input.amount), MAX_XP_AWARD);
const serializedMetadata = serializeMetadata(input.metadata);

try {
await db.$transaction(async (tx) => {
await tx.xpLedger.create({
data: {
userId: input.userId,
sourceType: input.sourceType,
sourceId: input.sourceId,
amount: xp,
metadata: serializedMetadata,
},
});

const updatedUser = await tx.user.update({
where: { id: input.userId },
data: {
xp: { increment: xp },
lastActiveAt: new Date(),
},
select: { xp: true },
});

await tx.user.update({
where: { id: input.userId },
data: {
level: Math.max(1, Math.floor(updatedUser.xp / 500) + 1),
},
});
});

return true;
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
return false;
}

throw error;
}
}

function serializeMetadata(metadata: unknown): string | undefined {
if (metadata === undefined) return undefined;

const serialized = JSON.stringify(metadata);
if (Buffer.byteLength(serialized, 'utf8') > MAX_METADATA_BYTES) {
throw new Error(`XP metadata exceeds ${MAX_METADATA_BYTES} bytes`);
}

return serialized;
}
Loading
Loading