Ship Novya PWA with backend for deployable production use.

Fix production TypeScript builds and add DEPLOY.md so the app can be hosted from this repo.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ar.badiei
2026-09-03 21:01:52 +03:30
co-authored by Cursor
parent aa9236fb92
commit 370ac15471
193 changed files with 25997 additions and 1991 deletions
+25
View File
@@ -0,0 +1,25 @@
export const APP_NAME = 'Novya API';
export const APP_DESCRIPTION = 'Novya learning platform backend API';
/** Minimum wait after completing session N before session N+1 unlocks. Set to 0 to disable cooldown (dev/testing). */
export const SESSION_UNLOCK_INTERVAL_MS = 0;
export const DEFAULT_PAGE = 1;
export const DEFAULT_PAGE_SIZE = 20;
export const MAX_PAGE_SIZE = 100;
export const REQUEST_ID_HEADER = 'x-request-id';
export const SENSITIVE_FIELD_PATTERNS = [
/password/i,
/otp/i,
/token/i,
/jwt/i,
/api[_-]?key/i,
/authorization/i,
/secret/i,
/prompt/i,
/refresh[_-]?token/i,
/answer/i,
/content/i,
] as const;
@@ -0,0 +1,14 @@
export const ERROR_CODES = {
INTERNAL_ERROR: 'INTERNAL_ERROR',
VALIDATION_ERROR: 'VALIDATION_ERROR',
NOT_FOUND: 'NOT_FOUND',
UNAUTHORIZED: 'UNAUTHORIZED',
FORBIDDEN: 'FORBIDDEN',
CONFLICT: 'CONFLICT',
RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED',
SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE',
DATABASE_UNAVAILABLE: 'DATABASE_UNAVAILABLE',
INVALID_CREDENTIALS: 'INVALID_CREDENTIALS',
} as const;
export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
@@ -0,0 +1,16 @@
export const HTTP_STATUS = {
OK: 200,
CREATED: 201,
NO_CONTENT: 204,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
CONFLICT: 409,
UNPROCESSABLE_ENTITY: 422,
TOO_MANY_REQUESTS: 429,
INTERNAL_SERVER_ERROR: 500,
SERVICE_UNAVAILABLE: 503,
} as const;
export type HttpStatusCode = (typeof HTTP_STATUS)[keyof typeof HTTP_STATUS];
+11
View File
@@ -0,0 +1,11 @@
export {
APP_NAME,
APP_DESCRIPTION,
DEFAULT_PAGE,
DEFAULT_PAGE_SIZE,
MAX_PAGE_SIZE,
REQUEST_ID_HEADER,
SENSITIVE_FIELD_PATTERNS,
} from './app.js';
export { HTTP_STATUS, type HttpStatusCode } from './httpStatus.js';
export { ERROR_CODES, type ErrorCode } from './errorCodes.js';
+102
View File
@@ -0,0 +1,102 @@
import { ERROR_CODES, HTTP_STATUS, type ErrorCode, type HttpStatusCode } from '../constants/index.js';
export class AppError extends Error {
readonly statusCode: HttpStatusCode;
readonly code: ErrorCode;
readonly details?: unknown;
readonly isOperational: boolean;
constructor(
message: string,
options: {
statusCode?: HttpStatusCode;
code?: ErrorCode;
details?: unknown;
isOperational?: boolean;
cause?: unknown;
} = {},
) {
super(message, { cause: options.cause });
this.name = 'AppError';
this.statusCode = options.statusCode ?? HTTP_STATUS.INTERNAL_SERVER_ERROR;
this.code = options.code ?? ERROR_CODES.INTERNAL_ERROR;
this.details = options.details;
this.isOperational = options.isOperational ?? true;
}
}
export class NotFoundError extends AppError {
constructor(message = 'Resource not found', details?: unknown) {
super(message, {
statusCode: HTTP_STATUS.NOT_FOUND,
code: ERROR_CODES.NOT_FOUND,
details,
});
this.name = 'NotFoundError';
}
}
export class ValidationError extends AppError {
constructor(message = 'Validation failed', details?: unknown) {
super(message, {
statusCode: HTTP_STATUS.UNPROCESSABLE_ENTITY,
code: ERROR_CODES.VALIDATION_ERROR,
details,
});
this.name = 'ValidationError';
}
}
export class ServiceUnavailableError extends AppError {
constructor(message = 'Service unavailable', details?: unknown) {
super(message, {
statusCode: HTTP_STATUS.SERVICE_UNAVAILABLE,
code: ERROR_CODES.SERVICE_UNAVAILABLE,
details,
});
this.name = 'ServiceUnavailableError';
}
}
export class UnauthorizedError extends AppError {
constructor(message = 'Authentication required', details?: unknown) {
super(message, {
statusCode: HTTP_STATUS.UNAUTHORIZED,
code: ERROR_CODES.UNAUTHORIZED,
details,
});
this.name = 'UnauthorizedError';
}
}
export class ConflictError extends AppError {
constructor(message = 'Resource already exists', details?: unknown) {
super(message, {
statusCode: HTTP_STATUS.CONFLICT,
code: ERROR_CODES.CONFLICT,
details,
});
this.name = 'ConflictError';
}
}
export class ForbiddenError extends AppError {
constructor(message = 'Forbidden', details?: unknown) {
super(message, {
statusCode: HTTP_STATUS.FORBIDDEN,
code: ERROR_CODES.FORBIDDEN,
details,
});
this.name = 'ForbiddenError';
}
}
export class InvalidCredentialsError extends AppError {
constructor() {
super('Invalid email or password', {
statusCode: HTTP_STATUS.UNAUTHORIZED,
code: ERROR_CODES.INVALID_CREDENTIALS,
});
this.name = 'InvalidCredentialsError';
}
}
+10
View File
@@ -0,0 +1,10 @@
export {
AppError,
NotFoundError,
ValidationError,
ServiceUnavailableError,
UnauthorizedError,
ConflictError,
ForbiddenError,
InvalidCredentialsError,
} from './AppError.js';
+44
View File
@@ -0,0 +1,44 @@
export interface ResponseMeta {
requestId: string;
timestamp: string;
}
export interface ApiSuccess<T> {
success: true;
data: T;
meta: ResponseMeta;
}
export interface ApiErrorBody {
code: string;
message: string;
details?: unknown;
}
export interface ApiError {
success: false;
error: ApiErrorBody;
meta: ResponseMeta;
}
export type BaseResponse<T> = ApiSuccess<T> | ApiError;
export interface Pagination {
page: number;
pageSize: number;
total: number;
totalPages: number;
}
export interface PaginatedData<T> {
items: T[];
pagination: Pagination;
}
export interface AuthenticatedUser {
id: bigint;
externalId: string;
email: string | null;
isGuest: boolean;
sessionId: bigint;
}
+14
View File
@@ -0,0 +1,14 @@
import type { AuthenticatedUser } from './api.js';
export interface RequestContext {
requestId: string;
user?: AuthenticatedUser;
}
declare module 'hono' {
interface ContextVariableMap {
requestId: string;
requestContext: RequestContext;
authUser: AuthenticatedUser;
}
}
+14
View File
@@ -0,0 +1,14 @@
export type {
ResponseMeta,
ApiSuccess,
ApiErrorBody,
ApiError,
BaseResponse,
Pagination,
PaginatedData,
AuthenticatedUser,
} from './api.js';
export type { RequestContext } from './context.js';
import './context.js';
+18
View File
@@ -0,0 +1,18 @@
import type { ZodType } from 'zod';
import { zValidator } from '@hono/zod-validator';
export function jsonValidator<T extends ZodType>(schema: T) {
return zValidator('json', schema, (result) => {
if (!result.success) {
throw result.error;
}
});
}
export function queryValidator<T extends ZodType>(schema: T) {
return zValidator('query', schema, (result) => {
if (!result.success) {
throw result.error;
}
});
}