Remove unused stub backend folder and add web frontend plan
Cleanup project structure by removing duplicate maternal-app-backend/ folder at root level. The working backend is located at maternal-app/maternal-app-backend/. Also added web frontend implementation plan documentation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1733
docs/maternal-web-frontend-plan.md
Normal file
1733
docs/maternal-web-frontend-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,18 +0,0 @@
|
||||
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export const getDatabaseConfig = (
|
||||
configService: ConfigService,
|
||||
): TypeOrmModuleOptions => ({
|
||||
type: 'postgres',
|
||||
host: configService.get<string>('DATABASE_HOST', 'localhost'),
|
||||
port: configService.get<number>('DATABASE_PORT', 5555),
|
||||
username: configService.get<string>('DATABASE_USER', 'maternal_user'),
|
||||
password: configService.get<string>('DATABASE_PASSWORD'),
|
||||
database: configService.get<string>('DATABASE_NAME', 'maternal_app'),
|
||||
entities: [__dirname + '/../**/*.entity{.ts,.js}'],
|
||||
migrations: [__dirname + '/../database/migrations/*{.ts,.js}'],
|
||||
synchronize: false, // Always use migrations in production
|
||||
logging: configService.get<string>('NODE_ENV') === 'development',
|
||||
ssl: configService.get<string>('NODE_ENV') === 'production',
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { getDatabaseConfig } from '../config/database.config';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: getDatabaseConfig,
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
@@ -1,60 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
CreateDateColumn,
|
||||
BeforeInsert,
|
||||
} from 'typeorm';
|
||||
import { Family } from './family.entity';
|
||||
|
||||
@Entity('children')
|
||||
export class Child {
|
||||
@PrimaryColumn({ length: 20 })
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'family_id', length: 20 })
|
||||
familyId: string;
|
||||
|
||||
@Column({ length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ name: 'birth_date', type: 'date' })
|
||||
birthDate: Date;
|
||||
|
||||
@Column({ length: 20, nullable: true })
|
||||
gender?: string;
|
||||
|
||||
@Column({ name: 'photo_url', type: 'text', nullable: true })
|
||||
photoUrl?: string;
|
||||
|
||||
@Column({ name: 'medical_info', type: 'jsonb', default: {} })
|
||||
medicalInfo: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@Column({ name: 'deleted_at', type: 'timestamp', nullable: true })
|
||||
deletedAt?: Date;
|
||||
|
||||
@ManyToOne(() => Family, (family) => family.children, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'family_id' })
|
||||
family: Family;
|
||||
|
||||
@BeforeInsert()
|
||||
generateId() {
|
||||
if (!this.id) {
|
||||
this.id = `chd_${this.generateNanoId()}`;
|
||||
}
|
||||
}
|
||||
|
||||
private generateNanoId(): string {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < 12; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
CreateDateColumn,
|
||||
BeforeInsert,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { User } from './user.entity';
|
||||
|
||||
@Entity('device_registry')
|
||||
@Index(['userId', 'deviceFingerprint'], { unique: true })
|
||||
export class DeviceRegistry {
|
||||
@PrimaryColumn({ length: 20 })
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'user_id', length: 20 })
|
||||
userId: string;
|
||||
|
||||
@Column({ name: 'device_fingerprint', length: 255 })
|
||||
deviceFingerprint: string;
|
||||
|
||||
@Column({ length: 20 })
|
||||
platform: string;
|
||||
|
||||
@Column({ default: false })
|
||||
trusted: boolean;
|
||||
|
||||
@Column({ name: 'last_seen', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
|
||||
lastSeen: Date;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.devices, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user: User;
|
||||
|
||||
@BeforeInsert()
|
||||
generateId() {
|
||||
if (!this.id) {
|
||||
this.id = `dev_${this.generateNanoId()}`;
|
||||
}
|
||||
}
|
||||
|
||||
private generateNanoId(): string {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < 12; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
CreateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from './user.entity';
|
||||
import { Family } from './family.entity';
|
||||
|
||||
export enum FamilyRole {
|
||||
PARENT = 'parent',
|
||||
CAREGIVER = 'caregiver',
|
||||
VIEWER = 'viewer',
|
||||
}
|
||||
|
||||
export interface FamilyPermissions {
|
||||
canAddChildren: boolean;
|
||||
canEditChildren: boolean;
|
||||
canLogActivities: boolean;
|
||||
canViewReports: boolean;
|
||||
}
|
||||
|
||||
@Entity('family_members')
|
||||
export class FamilyMember {
|
||||
@PrimaryColumn({ name: 'user_id', length: 20 })
|
||||
userId: string;
|
||||
|
||||
@PrimaryColumn({ name: 'family_id', length: 20 })
|
||||
familyId: string;
|
||||
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 20,
|
||||
enum: FamilyRole,
|
||||
})
|
||||
role: FamilyRole;
|
||||
|
||||
@Column({
|
||||
type: 'jsonb',
|
||||
default: {
|
||||
canAddChildren: false,
|
||||
canEditChildren: false,
|
||||
canLogActivities: true,
|
||||
canViewReports: true,
|
||||
},
|
||||
})
|
||||
permissions: FamilyPermissions;
|
||||
|
||||
@CreateDateColumn({ name: 'joined_at' })
|
||||
joinedAt: Date;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.familyMemberships, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user: User;
|
||||
|
||||
@ManyToOne(() => Family, (family) => family.members, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'family_id' })
|
||||
family: Family;
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
JoinColumn,
|
||||
CreateDateColumn,
|
||||
BeforeInsert,
|
||||
} from 'typeorm';
|
||||
import { User } from './user.entity';
|
||||
import { FamilyMember } from './family-member.entity';
|
||||
import { Child } from './child.entity';
|
||||
|
||||
@Entity('families')
|
||||
export class Family {
|
||||
@PrimaryColumn({ length: 20 })
|
||||
id: string;
|
||||
|
||||
@Column({ length: 100, nullable: true })
|
||||
name?: string;
|
||||
|
||||
@Column({ name: 'share_code', length: 10, unique: true })
|
||||
shareCode: string;
|
||||
|
||||
@Column({ name: 'created_by', length: 20 })
|
||||
createdBy: string;
|
||||
|
||||
@Column({ name: 'subscription_tier', length: 20, default: 'free' })
|
||||
subscriptionTier: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'created_by' })
|
||||
creator: User;
|
||||
|
||||
@OneToMany(() => FamilyMember, (member) => member.family)
|
||||
members: FamilyMember[];
|
||||
|
||||
@OneToMany(() => Child, (child) => child.family)
|
||||
children: Child[];
|
||||
|
||||
@BeforeInsert()
|
||||
generateId() {
|
||||
if (!this.id) {
|
||||
this.id = `fam_${this.generateNanoId()}`;
|
||||
}
|
||||
if (!this.shareCode) {
|
||||
this.shareCode = this.generateShareCode();
|
||||
}
|
||||
}
|
||||
|
||||
private generateNanoId(): string {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < 12; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private generateShareCode(): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < 6; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export { User } from './user.entity';
|
||||
export { DeviceRegistry } from './device-registry.entity';
|
||||
export { Family } from './family.entity';
|
||||
export { FamilyMember, FamilyRole, FamilyPermissions } from './family-member.entity';
|
||||
export { Child } from './child.entity';
|
||||
@@ -1,66 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
BeforeInsert,
|
||||
} from 'typeorm';
|
||||
import { DeviceRegistry } from './device-registry.entity';
|
||||
import { FamilyMember } from './family-member.entity';
|
||||
|
||||
@Entity('users')
|
||||
export class User {
|
||||
@PrimaryColumn({ length: 20 })
|
||||
id: string;
|
||||
|
||||
@Column({ length: 255, unique: true })
|
||||
email: string;
|
||||
|
||||
@Column({ length: 20, nullable: true })
|
||||
phone?: string;
|
||||
|
||||
@Column({ name: 'password_hash', length: 255 })
|
||||
passwordHash: string;
|
||||
|
||||
@Column({ length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ length: 10, default: 'en-US' })
|
||||
locale: string;
|
||||
|
||||
@Column({ length: 50, default: 'UTC' })
|
||||
timezone: string;
|
||||
|
||||
@Column({ name: 'email_verified', default: false })
|
||||
emailVerified: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
|
||||
@OneToMany(() => DeviceRegistry, (device) => device.user)
|
||||
devices: DeviceRegistry[];
|
||||
|
||||
@OneToMany(() => FamilyMember, (familyMember) => familyMember.user)
|
||||
familyMemberships: FamilyMember[];
|
||||
|
||||
@BeforeInsert()
|
||||
generateId() {
|
||||
if (!this.id) {
|
||||
this.id = `usr_${this.generateNanoId()}`;
|
||||
}
|
||||
}
|
||||
|
||||
private generateNanoId(): string {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < 12; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
-- V001_20240110120000_create_core_auth.sql
|
||||
-- Migration V001: Core Authentication Tables
|
||||
|
||||
-- Create extension for generating random IDs
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
-- Users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id VARCHAR(20) PRIMARY KEY,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
phone VARCHAR(20),
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
locale VARCHAR(10) DEFAULT 'en-US',
|
||||
timezone VARCHAR(50) DEFAULT 'UTC',
|
||||
email_verified BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Device registry table
|
||||
CREATE TABLE IF NOT EXISTS device_registry (
|
||||
id VARCHAR(20) PRIMARY KEY,
|
||||
user_id VARCHAR(20) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
device_fingerprint VARCHAR(255) NOT NULL,
|
||||
platform VARCHAR(20) NOT NULL,
|
||||
trusted BOOLEAN DEFAULT FALSE,
|
||||
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, device_fingerprint)
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_devices_user ON device_registry(user_id);
|
||||
|
||||
-- Update timestamp trigger function
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Apply trigger to users table
|
||||
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
@@ -1,41 +0,0 @@
|
||||
-- V002_20240110130000_create_family_structure.sql
|
||||
-- Migration V002: Family Structure
|
||||
|
||||
-- Families table
|
||||
CREATE TABLE IF NOT EXISTS families (
|
||||
id VARCHAR(20) PRIMARY KEY,
|
||||
name VARCHAR(100),
|
||||
share_code VARCHAR(10) UNIQUE,
|
||||
created_by VARCHAR(20) REFERENCES users(id),
|
||||
subscription_tier VARCHAR(20) DEFAULT 'free',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Family members table (junction table with additional data)
|
||||
CREATE TABLE IF NOT EXISTS family_members (
|
||||
user_id VARCHAR(20) REFERENCES users(id) ON DELETE CASCADE,
|
||||
family_id VARCHAR(20) REFERENCES families(id) ON DELETE CASCADE,
|
||||
role VARCHAR(20) NOT NULL CHECK (role IN ('parent', 'caregiver', 'viewer')),
|
||||
permissions JSONB DEFAULT '{"canAddChildren": false, "canEditChildren": false, "canLogActivities": true, "canViewReports": true}'::jsonb,
|
||||
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, family_id)
|
||||
);
|
||||
|
||||
-- Children table
|
||||
CREATE TABLE IF NOT EXISTS children (
|
||||
id VARCHAR(20) PRIMARY KEY,
|
||||
family_id VARCHAR(20) NOT NULL REFERENCES families(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
birth_date DATE NOT NULL,
|
||||
gender VARCHAR(20),
|
||||
photo_url TEXT,
|
||||
medical_info JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_families_share_code ON families(share_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_family_members_family ON family_members(family_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_children_family ON children(family_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_children_active ON children(deleted_at) WHERE deleted_at IS NULL;
|
||||
@@ -1,20 +0,0 @@
|
||||
-- V003_20240110140000_create_refresh_tokens.sql
|
||||
-- Migration V003: Refresh Tokens Table
|
||||
|
||||
-- Refresh tokens table for JWT authentication
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id VARCHAR(20) PRIMARY KEY,
|
||||
user_id VARCHAR(20) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
device_id VARCHAR(20) REFERENCES device_registry(id) ON DELETE CASCADE,
|
||||
token_hash VARCHAR(255) NOT NULL,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
revoked BOOLEAN DEFAULT FALSE,
|
||||
revoked_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Indexes for refresh token lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_device ON refresh_tokens(device_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_active ON refresh_tokens(expires_at, revoked) WHERE revoked = FALSE;
|
||||
@@ -1,79 +0,0 @@
|
||||
import { Client } from 'pg';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as dotenv from 'dotenv';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
const client = new Client({
|
||||
host: process.env.DATABASE_HOST || 'localhost',
|
||||
port: parseInt(process.env.DATABASE_PORT || '5555'),
|
||||
user: process.env.DATABASE_USER || 'maternal_user',
|
||||
password: process.env.DATABASE_PASSWORD,
|
||||
database: process.env.DATABASE_NAME || 'maternal_app',
|
||||
});
|
||||
|
||||
const MIGRATIONS_DIR = __dirname;
|
||||
|
||||
async function runMigrations() {
|
||||
try {
|
||||
await client.connect();
|
||||
console.log('Connected to database');
|
||||
|
||||
// Create migrations tracking table
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version VARCHAR(50) PRIMARY KEY,
|
||||
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
// Get list of migration files
|
||||
const files = fs
|
||||
.readdirSync(MIGRATIONS_DIR)
|
||||
.filter((file) => file.startsWith('V') && file.endsWith('.sql'))
|
||||
.sort();
|
||||
|
||||
console.log(`Found ${files.length} migration files`);
|
||||
|
||||
for (const file of files) {
|
||||
const version = file.split('_')[0]; // Extract V001, V002, etc.
|
||||
|
||||
// Check if migration already executed
|
||||
const result = await client.query(
|
||||
'SELECT version FROM schema_migrations WHERE version = $1',
|
||||
[version],
|
||||
);
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
console.log(`✓ Migration ${version} already executed`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read and execute migration
|
||||
const migrationPath = path.join(MIGRATIONS_DIR, file);
|
||||
const sql = fs.readFileSync(migrationPath, 'utf-8');
|
||||
|
||||
console.log(`Running migration ${version}...`);
|
||||
await client.query(sql);
|
||||
|
||||
// Record migration
|
||||
await client.query(
|
||||
'INSERT INTO schema_migrations (version) VALUES ($1)',
|
||||
[version],
|
||||
);
|
||||
|
||||
console.log(`✓ Migration ${version} completed`);
|
||||
}
|
||||
|
||||
console.log('All migrations completed successfully');
|
||||
} catch (error) {
|
||||
console.error('Migration error:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
runMigrations();
|
||||
Reference in New Issue
Block a user