Setup initial project
This commit is contained in:
commit
db79af06f7
119 changed files with 20761 additions and 0 deletions
38
packages/shared/eslint.config.js
Normal file
38
packages/shared/eslint.config.js
Normal 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,
|
||||
);
|
||||
37
packages/shared/package.json
Normal file
37
packages/shared/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
packages/shared/src/enums/index.ts
Normal file
1
packages/shared/src/enums/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './roles.enums.js';
|
||||
14
packages/shared/src/enums/roles.enums.test.ts
Normal file
14
packages/shared/src/enums/roles.enums.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
5
packages/shared/src/enums/roles.enums.ts
Normal file
5
packages/shared/src/enums/roles.enums.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export enum HouseholdRole {
|
||||
OWNER = 'owner',
|
||||
ADMIN = 'admin',
|
||||
MEMBER = 'member',
|
||||
}
|
||||
8
packages/shared/src/index.ts
Normal file
8
packages/shared/src/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Types
|
||||
export * from './types/index.js';
|
||||
|
||||
// Enums
|
||||
export * from './enums/index.js';
|
||||
|
||||
// Validation schemas
|
||||
export * from './validation/index.js';
|
||||
28
packages/shared/src/types/common.ts
Normal file
28
packages/shared/src/types/common.ts
Normal 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;
|
||||
}
|
||||
24
packages/shared/src/types/household.ts
Normal file
24
packages/shared/src/types/household.ts
Normal 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;
|
||||
}
|
||||
3
packages/shared/src/types/index.ts
Normal file
3
packages/shared/src/types/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './user.js';
|
||||
export * from './household.js';
|
||||
export * from './common.js';
|
||||
10
packages/shared/src/types/user.ts
Normal file
10
packages/shared/src/types/user.ts
Normal 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;
|
||||
}
|
||||
136
packages/shared/src/validation/household.schemas.test.ts
Normal file
136
packages/shared/src/validation/household.schemas.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
54
packages/shared/src/validation/household.schemas.ts
Normal file
54
packages/shared/src/validation/household.schemas.ts
Normal 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>;
|
||||
2
packages/shared/src/validation/index.ts
Normal file
2
packages/shared/src/validation/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './user.schemas.js';
|
||||
export * from './household.schemas.js';
|
||||
84
packages/shared/src/validation/user.schemas.test.ts
Normal file
84
packages/shared/src/validation/user.schemas.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
14
packages/shared/src/validation/user.schemas.ts
Normal file
14
packages/shared/src/validation/user.schemas.ts
Normal 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>;
|
||||
12
packages/shared/tsconfig.json
Normal file
12
packages/shared/tsconfig.json
Normal 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"]
|
||||
}
|
||||
8
packages/shared/tsconfig.test.json
Normal file
8
packages/shared/tsconfig.test.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": []
|
||||
}
|
||||
30
packages/shared/vitest.config.ts
Normal file
30
packages/shared/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue