Setup initial project

This commit is contained in:
Aerilyn Weber 2026-03-27 14:50:34 +09:00
commit db79af06f7
119 changed files with 20761 additions and 0 deletions

View file

@ -0,0 +1,38 @@
import tseslint from 'typescript-eslint';
import prettierRecommended from 'eslint-plugin-prettier/recommended';
export default tseslint.config(
{ ignores: ['dist/**', 'coverage/**'] },
...tseslint.configs.recommended,
{
languageOptions: {
parserOptions: {
tsconfigRootDir: import.meta.dirname, // points to packages/shared
project: ['./tsconfig.json', './tsconfig.test.json'],
},
},
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/explicit-member-accessibility': ['error', { accessibility: 'explicit' }],
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'@typescript-eslint/consistent-type-imports': [
'error',
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
],
},
},
{
files: ['**/*.test.ts'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
},
},
prettierRecommended,
);

View file

@ -0,0 +1,37 @@
{
"name": "@meshitrack/shared",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./validation": {
"types": "./dist/validation/index.d.ts",
"import": "./dist/validation/index.js"
}
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"clean": "rimraf dist tsconfig.tsbuildinfo",
"typecheck": "tsc --noEmit",
"lint": "eslint src",
"lint-fix": "eslint src --fix",
"test": "vitest run",
"test:cov": "vitest run --coverage"
},
"dependencies": {
"zod": "^4.3.0"
},
"devDependencies": {
"@vitest/coverage-v8": "^4.1.1",
"rimraf": "^6.0.0",
"typescript": "^6.0.0",
"vitest": "^4.1.1"
}
}

View file

@ -0,0 +1 @@
export * from './roles.enums.js';

View file

@ -0,0 +1,14 @@
import { describe, it, expect } from 'vitest';
import { HouseholdRole } from './roles.enums.js';
describe('HouseholdRole', () => {
it('has OWNER, ADMIN, MEMBER values', () => {
expect(HouseholdRole.OWNER).toBe('owner');
expect(HouseholdRole.ADMIN).toBe('admin');
expect(HouseholdRole.MEMBER).toBe('member');
});
it('has exactly 3 roles', () => {
expect(Object.values(HouseholdRole)).toHaveLength(3);
});
});

View file

@ -0,0 +1,5 @@
export enum HouseholdRole {
OWNER = 'owner',
ADMIN = 'admin',
MEMBER = 'member',
}

View file

@ -0,0 +1,8 @@
// Types
export * from './types/index.js';
// Enums
export * from './enums/index.js';
// Validation schemas
export * from './validation/index.js';

View file

@ -0,0 +1,28 @@
export interface PaginatedResponse<T> {
data: T[];
pagination: {
cursor: string | null;
hasMore: boolean;
total?: number;
};
}
export interface ApiError {
statusCode: number;
error: string;
message: string;
details?: Record<string, string[]>;
timestamp: string;
path: string;
}
export interface ApiSuccess<T> {
data: T;
message?: string;
}
export interface HealthResponse {
status: 'ok';
version: string;
uptime: number;
}

View file

@ -0,0 +1,24 @@
import type { HouseholdRole } from '../enums/roles.enums.js';
export interface Household {
id: string;
name: string;
ownerUserId: string;
members: HouseholdMember[];
inviteCode: string;
settings: HouseholdSettings;
createdAt: Date;
updatedAt: Date;
}
export interface HouseholdMember {
userId: string;
role: HouseholdRole;
joinedAt: Date;
}
export interface HouseholdSettings {
timezone: string;
currency: string;
language: string;
}

View file

@ -0,0 +1,3 @@
export * from './user.js';
export * from './household.js';
export * from './common.js';

View file

@ -0,0 +1,10 @@
export interface User {
id: string;
keycloakId: string;
displayName: string;
email: string;
householdIds: string[];
defaultHouseholdId: string | null;
createdAt: Date;
updatedAt: Date;
}

View file

@ -0,0 +1,136 @@
import { describe, it, expect } from 'vitest';
import {
HouseholdSettingsSchema,
CreateHouseholdSchema,
UpdateHouseholdSchema,
JoinHouseholdSchema,
HouseholdMemberSchema,
} from './household.schemas.js';
import { HouseholdRole } from '../enums/roles.enums.js';
describe('HouseholdSettingsSchema', () => {
it('accepts empty object with defaults', () => {
const result = HouseholdSettingsSchema.safeParse({});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.timezone).toBe('UTC');
expect(result.data.currency).toBe('USD');
expect(result.data.language).toBe('en');
}
});
it('accepts custom settings', () => {
const result = HouseholdSettingsSchema.safeParse({
timezone: 'Asia/Tokyo',
currency: 'JPY',
language: 'ja',
});
expect(result.success).toBe(true);
});
it('rejects currency exceeding 3 chars', () => {
const result = HouseholdSettingsSchema.safeParse({ currency: 'LONG' });
expect(result.success).toBe(false);
});
it('rejects language exceeding 5 chars', () => {
const result = HouseholdSettingsSchema.safeParse({ language: 'toolong' });
expect(result.success).toBe(false);
});
});
describe('CreateHouseholdSchema', () => {
it('accepts valid input', () => {
const result = CreateHouseholdSchema.safeParse({ name: 'My Household' });
expect(result.success).toBe(true);
});
it('rejects empty name', () => {
const result = CreateHouseholdSchema.safeParse({ name: '' });
expect(result.success).toBe(false);
});
it('rejects name exceeding 100 chars', () => {
const result = CreateHouseholdSchema.safeParse({ name: 'x'.repeat(101) });
expect(result.success).toBe(false);
});
it('trims name', () => {
const result = CreateHouseholdSchema.safeParse({ name: ' Test ' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.name).toBe('Test');
}
});
it('accepts optional settings', () => {
const result = CreateHouseholdSchema.safeParse({
name: 'Test',
settings: { timezone: 'US/Eastern' },
});
expect(result.success).toBe(true);
});
});
describe('UpdateHouseholdSchema', () => {
it('accepts partial updates', () => {
const result = UpdateHouseholdSchema.safeParse({ name: 'Updated' });
expect(result.success).toBe(true);
});
it('accepts empty object', () => {
const result = UpdateHouseholdSchema.safeParse({});
expect(result.success).toBe(true);
});
it('accepts partial settings', () => {
const result = UpdateHouseholdSchema.safeParse({ settings: { currency: 'EUR' } });
expect(result.success).toBe(true);
});
});
describe('JoinHouseholdSchema', () => {
it('accepts valid invite code', () => {
const result = JoinHouseholdSchema.safeParse({ inviteCode: 'ABC123' });
expect(result.success).toBe(true);
});
it('rejects empty invite code', () => {
const result = JoinHouseholdSchema.safeParse({ inviteCode: '' });
expect(result.success).toBe(false);
});
it('rejects missing invite code', () => {
const result = JoinHouseholdSchema.safeParse({});
expect(result.success).toBe(false);
});
});
describe('HouseholdMemberSchema', () => {
it('accepts valid member', () => {
const result = HouseholdMemberSchema.safeParse({
userId: 'user-1',
role: HouseholdRole.OWNER,
joinedAt: new Date(),
});
expect(result.success).toBe(true);
});
it('rejects invalid role', () => {
const result = HouseholdMemberSchema.safeParse({
userId: 'user-1',
role: 'superadmin',
joinedAt: new Date(),
});
expect(result.success).toBe(false);
});
it('rejects empty userId', () => {
const result = HouseholdMemberSchema.safeParse({
userId: '',
role: HouseholdRole.MEMBER,
joinedAt: new Date(),
});
expect(result.success).toBe(false);
});
});

View file

@ -0,0 +1,54 @@
import { z } from 'zod/v4';
import { HouseholdRole } from '../enums/roles.enums.js';
export const HouseholdSettingsSchema = z.object({
timezone: z.string().default('UTC'),
currency: z.string().max(3).default('USD'),
language: z.string().max(5).default('en'),
});
export const CreateHouseholdSchema = z.object({
name: z.string().min(1).max(100).trim(),
settings: HouseholdSettingsSchema.optional(),
});
export const UpdateHouseholdSchema = z.object({
name: z.string().min(1).max(100).trim().optional(),
settings: HouseholdSettingsSchema.partial().optional(),
});
export const JoinHouseholdSchema = z.object({
inviteCode: z.string().min(1),
});
export const HouseholdMemberSchema = z.object({
userId: z.string().min(1),
role: z.nativeEnum(HouseholdRole),
joinedAt: z.date(),
});
// Response schema for API output — dates are ISO strings after JSON serialization.
export const HouseholdResponseSchema = z.object({
_id: z.string(),
name: z.string(),
ownerUserId: z.string(),
members: z.array(
z.object({
userId: z.string(),
role: z.string(),
joinedAt: z.string(),
}),
),
inviteCode: z.string(),
settings: z.object({
timezone: z.string(),
currency: z.string(),
language: z.string(),
}),
createdAt: z.string(),
updatedAt: z.string(),
});
export type CreateHouseholdInput = z.infer<typeof CreateHouseholdSchema>;
export type UpdateHouseholdInput = z.infer<typeof UpdateHouseholdSchema>;
export type JoinHouseholdInput = z.infer<typeof JoinHouseholdSchema>;

View file

@ -0,0 +1,2 @@
export * from './user.schemas.js';
export * from './household.schemas.js';

View file

@ -0,0 +1,84 @@
import { describe, it, expect } from 'vitest';
import { CreateUserSchema, UpdateUserSchema } from './user.schemas.js';
describe('CreateUserSchema', () => {
it('accepts valid input', () => {
const result = CreateUserSchema.safeParse({
keycloakId: 'kc-1',
displayName: 'Test User',
email: 'test@example.com',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.householdIds).toEqual([]);
}
});
it('rejects missing keycloakId', () => {
const result = CreateUserSchema.safeParse({
displayName: 'Test',
email: 'test@example.com',
});
expect(result.success).toBe(false);
});
it('rejects empty keycloakId', () => {
const result = CreateUserSchema.safeParse({
keycloakId: '',
displayName: 'Test',
email: 'test@example.com',
});
expect(result.success).toBe(false);
});
it('rejects invalid email', () => {
const result = CreateUserSchema.safeParse({
keycloakId: 'kc-1',
displayName: 'Test',
email: 'not-email',
});
expect(result.success).toBe(false);
});
it('trims displayName', () => {
const result = CreateUserSchema.safeParse({
keycloakId: 'kc-1',
displayName: ' Test ',
email: 'test@example.com',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.displayName).toBe('Test');
}
});
it('rejects displayName exceeding 100 chars', () => {
const result = CreateUserSchema.safeParse({
keycloakId: 'kc-1',
displayName: 'x'.repeat(101),
email: 'test@example.com',
});
expect(result.success).toBe(false);
});
});
describe('UpdateUserSchema', () => {
it('accepts partial updates', () => {
const result = UpdateUserSchema.safeParse({ displayName: 'New Name' });
expect(result.success).toBe(true);
});
it('accepts empty object (all fields optional)', () => {
const result = UpdateUserSchema.safeParse({});
expect(result.success).toBe(true);
});
it('does not allow keycloakId', () => {
const result = UpdateUserSchema.safeParse({ keycloakId: 'kc-1' });
// keycloakId is omitted, so it should be stripped or rejected
expect(result.success).toBe(true);
if (result.success) {
expect('keycloakId' in result.data).toBe(false);
}
});
});

View file

@ -0,0 +1,14 @@
import { z } from 'zod/v4';
export const CreateUserSchema = z.object({
keycloakId: z.string().min(1),
displayName: z.string().min(1).max(100).trim(),
email: z.email(),
householdIds: z.array(z.string()).default([]),
defaultHouseholdId: z.string().optional(),
});
export const UpdateUserSchema = CreateUserSchema.partial().omit({ keycloakId: true });
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>;

View file

@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["src/**/*.test.ts"]
}

View file

@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true
},
"include": ["src/**/*"],
"exclude": []
}

View file

@ -0,0 +1,30 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
coverage: {
provider: 'v8',
enabled: false,
include: ['src/**/*.ts'],
exclude: [
'src/**/*.test.ts',
'src/index.ts',
'src/types/**', // pure type declarations
'src/enums/index.ts',
'src/validation/index.ts',
],
reporter: ['text', 'lcov', 'json-summary', 'html'],
reportsDirectory: './coverage',
thresholds: {
lines: 100,
functions: 100,
branches: 90,
statements: 100,
},
},
testTimeout: 10_000,
},
});