This commit is contained in:
Till-JS 2025-12-09 12:27:57 +01:00
commit 241dc6173e
89 changed files with 5246 additions and 3492 deletions

View file

@ -1,662 +0,0 @@
[
{
"timestamp": 1764952212026,
"memoryTotal": 34359738368,
"memoryUsed": 34261319680,
"memoryFree": 98418688,
"memoryUsagePercent": 99.71356391906738,
"memoryEfficiency": 0.2864360809326172,
"cpuCount": 12,
"cpuLoad": 0.18159993489583334,
"platform": "darwin",
"uptime": 316234
},
{
"timestamp": 1764952242025,
"memoryTotal": 34359738368,
"memoryUsed": 33978384384,
"memoryFree": 381353984,
"memoryUsagePercent": 98.8901138305664,
"memoryEfficiency": 1.1098861694335938,
"cpuCount": 12,
"cpuLoad": 0.3077799479166667,
"platform": "darwin",
"uptime": 316264
},
{
"timestamp": 1764952272027,
"memoryTotal": 34359738368,
"memoryUsed": 31443189760,
"memoryFree": 2916548608,
"memoryUsagePercent": 91.51172637939453,
"memoryEfficiency": 8.488273620605469,
"cpuCount": 12,
"cpuLoad": 0.3214518229166667,
"platform": "darwin",
"uptime": 316294
},
{
"timestamp": 1764952302028,
"memoryTotal": 34359738368,
"memoryUsed": 32673497088,
"memoryFree": 1686241280,
"memoryUsagePercent": 95.09239196777344,
"memoryEfficiency": 4.9076080322265625,
"cpuCount": 12,
"cpuLoad": 0.2928873697916667,
"platform": "darwin",
"uptime": 316324
},
{
"timestamp": 1764952332029,
"memoryTotal": 34359738368,
"memoryUsed": 33272102912,
"memoryFree": 1087635456,
"memoryUsagePercent": 96.83456420898438,
"memoryEfficiency": 3.165435791015625,
"cpuCount": 12,
"cpuLoad": 0.2457275390625,
"platform": "darwin",
"uptime": 316354
},
{
"timestamp": 1764952362030,
"memoryTotal": 34359738368,
"memoryUsed": 33491288064,
"memoryFree": 868450304,
"memoryUsagePercent": 97.47247695922852,
"memoryEfficiency": 2.5275230407714844,
"cpuCount": 12,
"cpuLoad": 0.21903483072916666,
"platform": "darwin",
"uptime": 316384
},
{
"timestamp": 1764952392031,
"memoryTotal": 34359738368,
"memoryUsed": 33697579008,
"memoryFree": 662159360,
"memoryUsagePercent": 98.07286262512207,
"memoryEfficiency": 1.9271373748779297,
"cpuCount": 12,
"cpuLoad": 0.19921875,
"platform": "darwin",
"uptime": 316414
},
{
"timestamp": 1764952422033,
"memoryTotal": 34359738368,
"memoryUsed": 33743257600,
"memoryFree": 616480768,
"memoryUsagePercent": 98.2058048248291,
"memoryEfficiency": 1.7941951751708984,
"cpuCount": 12,
"cpuLoad": 0.3559977213541667,
"platform": "darwin",
"uptime": 316444
},
{
"timestamp": 1764952454157,
"memoryTotal": 34359738368,
"memoryUsed": 34170847232,
"memoryFree": 188891136,
"memoryUsagePercent": 99.45025444030762,
"memoryEfficiency": 0.5497455596923828,
"cpuCount": 12,
"cpuLoad": 0.8998616536458334,
"platform": "darwin",
"uptime": 316476
},
{
"timestamp": 1764952484155,
"memoryTotal": 34359738368,
"memoryUsed": 34279800832,
"memoryFree": 79937536,
"memoryUsagePercent": 99.7673511505127,
"memoryEfficiency": 0.2326488494873047,
"cpuCount": 12,
"cpuLoad": 0.6012369791666666,
"platform": "darwin",
"uptime": 316506
},
{
"timestamp": 1764952516186,
"memoryTotal": 34359738368,
"memoryUsed": 34296479744,
"memoryFree": 63258624,
"memoryUsagePercent": 99.81589317321777,
"memoryEfficiency": 0.18410682678222656,
"cpuCount": 12,
"cpuLoad": 0.4691975911458333,
"platform": "darwin",
"uptime": 316538
},
{
"timestamp": 1764952548258,
"memoryTotal": 34359738368,
"memoryUsed": 34266447872,
"memoryFree": 93290496,
"memoryUsagePercent": 99.72848892211914,
"memoryEfficiency": 0.2715110778808594,
"cpuCount": 12,
"cpuLoad": 0.340087890625,
"platform": "darwin",
"uptime": 316570
},
{
"timestamp": 1764952578260,
"memoryTotal": 34359738368,
"memoryUsed": 34260549632,
"memoryFree": 99188736,
"memoryUsagePercent": 99.71132278442383,
"memoryEfficiency": 0.2886772155761719,
"cpuCount": 12,
"cpuLoad": 0.2496337890625,
"platform": "darwin",
"uptime": 316600
},
{
"timestamp": 1764952609578,
"memoryTotal": 34359738368,
"memoryUsed": 33692418048,
"memoryFree": 667320320,
"memoryUsagePercent": 98.05784225463867,
"memoryEfficiency": 1.9421577453613281,
"cpuCount": 12,
"cpuLoad": 0.19681803385416666,
"platform": "darwin",
"uptime": 316631
},
{
"timestamp": 1764952641004,
"memoryTotal": 34359738368,
"memoryUsed": 34198962176,
"memoryFree": 160776192,
"memoryUsagePercent": 99.53207969665527,
"memoryEfficiency": 0.46792030334472656,
"cpuCount": 12,
"cpuLoad": 0.23486328125,
"platform": "darwin",
"uptime": 316663
},
{
"timestamp": 1764952671007,
"memoryTotal": 34359738368,
"memoryUsed": 34222686208,
"memoryFree": 137052160,
"memoryUsagePercent": 99.60112571716309,
"memoryEfficiency": 0.39887428283691406,
"cpuCount": 12,
"cpuLoad": 0.18770345052083334,
"platform": "darwin",
"uptime": 316693
},
{
"timestamp": 1764952702795,
"memoryTotal": 34359738368,
"memoryUsed": 34269872128,
"memoryFree": 89866240,
"memoryUsagePercent": 99.73845481872559,
"memoryEfficiency": 0.26154518127441406,
"cpuCount": 12,
"cpuLoad": 0.15791829427083334,
"platform": "darwin",
"uptime": 316724
},
{
"timestamp": 1764952732797,
"memoryTotal": 34359738368,
"memoryUsed": 34242723840,
"memoryFree": 117014528,
"memoryUsagePercent": 99.65944290161133,
"memoryEfficiency": 0.3405570983886719,
"cpuCount": 12,
"cpuLoad": 0.12320963541666667,
"platform": "darwin",
"uptime": 316754
},
{
"timestamp": 1764952764879,
"memoryTotal": 34359738368,
"memoryUsed": 34213707776,
"memoryFree": 146030592,
"memoryUsagePercent": 99.57499504089355,
"memoryEfficiency": 0.4250049591064453,
"cpuCount": 12,
"cpuLoad": 0.11909993489583333,
"platform": "darwin",
"uptime": 316786
},
{
"timestamp": 1764952796332,
"memoryTotal": 34359738368,
"memoryUsed": 34039250944,
"memoryFree": 320487424,
"memoryUsagePercent": 99.06725883483887,
"memoryEfficiency": 0.9327411651611328,
"cpuCount": 12,
"cpuLoad": 0.135009765625,
"platform": "darwin",
"uptime": 316818
},
{
"timestamp": 1764952826334,
"memoryTotal": 34359738368,
"memoryUsed": 34186772480,
"memoryFree": 172965888,
"memoryUsagePercent": 99.49660301208496,
"memoryEfficiency": 0.5033969879150391,
"cpuCount": 12,
"cpuLoad": 0.21162923177083334,
"platform": "darwin",
"uptime": 316848
},
{
"timestamp": 1764952858356,
"memoryTotal": 34359738368,
"memoryUsed": 34186379264,
"memoryFree": 173359104,
"memoryUsagePercent": 99.49545860290527,
"memoryEfficiency": 0.5045413970947266,
"cpuCount": 12,
"cpuLoad": 0.2408447265625,
"platform": "darwin",
"uptime": 316880
},
{
"timestamp": 1764952889730,
"memoryTotal": 34359738368,
"memoryUsed": 34280357888,
"memoryFree": 79380480,
"memoryUsagePercent": 99.76897239685059,
"memoryEfficiency": 0.23102760314941406,
"cpuCount": 12,
"cpuLoad": 0.3212890625,
"platform": "darwin",
"uptime": 316911
},
{
"timestamp": 1764952919730,
"memoryTotal": 34359738368,
"memoryUsed": 33718976512,
"memoryFree": 640761856,
"memoryUsagePercent": 98.1351375579834,
"memoryEfficiency": 1.8648624420166016,
"cpuCount": 12,
"cpuLoad": 0.2577311197916667,
"platform": "darwin",
"uptime": 316941
},
{
"timestamp": 1764952949731,
"memoryTotal": 34359738368,
"memoryUsed": 33727643648,
"memoryFree": 632094720,
"memoryUsagePercent": 98.16036224365234,
"memoryEfficiency": 1.8396377563476562,
"cpuCount": 12,
"cpuLoad": 0.2508138020833333,
"platform": "darwin",
"uptime": 316971
},
{
"timestamp": 1764952979732,
"memoryTotal": 34359738368,
"memoryUsed": 33519927296,
"memoryFree": 839811072,
"memoryUsagePercent": 97.55582809448242,
"memoryEfficiency": 2.444171905517578,
"cpuCount": 12,
"cpuLoad": 1.6138916015625,
"platform": "darwin",
"uptime": 317001
},
{
"timestamp": 1764953009732,
"memoryTotal": 34359738368,
"memoryUsed": 33729118208,
"memoryFree": 630620160,
"memoryUsagePercent": 98.16465377807617,
"memoryEfficiency": 1.8353462219238281,
"cpuCount": 12,
"cpuLoad": 1.1908365885416667,
"platform": "darwin",
"uptime": 317031
},
{
"timestamp": 1764953039733,
"memoryTotal": 34359738368,
"memoryUsed": 34194341888,
"memoryFree": 165396480,
"memoryUsagePercent": 99.51863288879395,
"memoryEfficiency": 0.4813671112060547,
"cpuCount": 12,
"cpuLoad": 0.8716227213541666,
"platform": "darwin",
"uptime": 317061
},
{
"timestamp": 1764953069734,
"memoryTotal": 34359738368,
"memoryUsed": 34195587072,
"memoryFree": 164151296,
"memoryUsagePercent": 99.52225685119629,
"memoryEfficiency": 0.47774314880371094,
"cpuCount": 12,
"cpuLoad": 0.6322835286458334,
"platform": "darwin",
"uptime": 317091
},
{
"timestamp": 1764953099734,
"memoryTotal": 34359738368,
"memoryUsed": 34240970752,
"memoryFree": 118767616,
"memoryUsagePercent": 99.65434074401855,
"memoryEfficiency": 0.3456592559814453,
"cpuCount": 12,
"cpuLoad": 0.5132242838541666,
"platform": "darwin",
"uptime": 317121
},
{
"timestamp": 1764953129734,
"memoryTotal": 34359738368,
"memoryUsed": 34246590464,
"memoryFree": 113147904,
"memoryUsagePercent": 99.67069625854492,
"memoryEfficiency": 0.3293037414550781,
"cpuCount": 12,
"cpuLoad": 0.4134521484375,
"platform": "darwin",
"uptime": 317151
},
{
"timestamp": 1764953159735,
"memoryTotal": 34359738368,
"memoryUsed": 34252914688,
"memoryFree": 106823680,
"memoryUsagePercent": 99.68910217285156,
"memoryEfficiency": 0.3108978271484375,
"cpuCount": 12,
"cpuLoad": 0.5005696614583334,
"platform": "darwin",
"uptime": 317181
},
{
"timestamp": 1764953189735,
"memoryTotal": 34359738368,
"memoryUsed": 34234728448,
"memoryFree": 125009920,
"memoryUsagePercent": 99.63617324829102,
"memoryEfficiency": 0.3638267517089844,
"cpuCount": 12,
"cpuLoad": 0.3972981770833333,
"platform": "darwin",
"uptime": 317211
},
{
"timestamp": 1764953219736,
"memoryTotal": 34359738368,
"memoryUsed": 33779810304,
"memoryFree": 579928064,
"memoryUsagePercent": 98.31218719482422,
"memoryEfficiency": 1.6878128051757812,
"cpuCount": 12,
"cpuLoad": 0.3188883463541667,
"platform": "darwin",
"uptime": 317241
},
{
"timestamp": 1764953249736,
"memoryTotal": 34359738368,
"memoryUsed": 34159230976,
"memoryFree": 200507392,
"memoryUsagePercent": 99.41644668579102,
"memoryEfficiency": 0.5835533142089844,
"cpuCount": 12,
"cpuLoad": 0.2990315755208333,
"platform": "darwin",
"uptime": 317271
},
{
"timestamp": 1764953279736,
"memoryTotal": 34359738368,
"memoryUsed": 34207203328,
"memoryFree": 152535040,
"memoryUsagePercent": 99.55606460571289,
"memoryEfficiency": 0.4439353942871094,
"cpuCount": 12,
"cpuLoad": 0.4497477213541667,
"platform": "darwin",
"uptime": 317301
},
{
"timestamp": 1764953309737,
"memoryTotal": 34359738368,
"memoryUsed": 34149695488,
"memoryFree": 210042880,
"memoryUsagePercent": 99.3886947631836,
"memoryEfficiency": 0.6113052368164062,
"cpuCount": 12,
"cpuLoad": 0.3463541666666667,
"platform": "darwin",
"uptime": 317331
},
{
"timestamp": 1764953339738,
"memoryTotal": 34359738368,
"memoryUsed": 33949761536,
"memoryFree": 409976832,
"memoryUsagePercent": 98.80681037902832,
"memoryEfficiency": 1.1931896209716797,
"cpuCount": 12,
"cpuLoad": 0.3301188151041667,
"platform": "darwin",
"uptime": 317361
},
{
"timestamp": 1764953369738,
"memoryTotal": 34359738368,
"memoryUsed": 34282471424,
"memoryFree": 77266944,
"memoryUsagePercent": 99.7751235961914,
"memoryEfficiency": 0.22487640380859375,
"cpuCount": 12,
"cpuLoad": 0.28466796875,
"platform": "darwin",
"uptime": 317391
},
{
"timestamp": 1764953399740,
"memoryTotal": 34359738368,
"memoryUsed": 32868974592,
"memoryFree": 1490763776,
"memoryUsagePercent": 95.66130638122559,
"memoryEfficiency": 4.338693618774414,
"cpuCount": 12,
"cpuLoad": 0.224365234375,
"platform": "darwin",
"uptime": 317421
},
{
"timestamp": 1764953429741,
"memoryTotal": 34359738368,
"memoryUsed": 33954299904,
"memoryFree": 405438464,
"memoryUsagePercent": 98.82001876831055,
"memoryEfficiency": 1.1799812316894531,
"cpuCount": 12,
"cpuLoad": 0.18465169270833334,
"platform": "darwin",
"uptime": 317451
},
{
"timestamp": 1764953459742,
"memoryTotal": 34359738368,
"memoryUsed": 33574420480,
"memoryFree": 785317888,
"memoryUsagePercent": 97.71442413330078,
"memoryEfficiency": 2.2855758666992188,
"cpuCount": 12,
"cpuLoad": 0.1524658203125,
"platform": "darwin",
"uptime": 317481
},
{
"timestamp": 1764953489742,
"memoryTotal": 34359738368,
"memoryUsed": 33800306688,
"memoryFree": 559431680,
"memoryUsagePercent": 98.37183952331543,
"memoryEfficiency": 1.6281604766845703,
"cpuCount": 12,
"cpuLoad": 0.20756022135416666,
"platform": "darwin",
"uptime": 317511
},
{
"timestamp": 1764953519743,
"memoryTotal": 34359738368,
"memoryUsed": 34025553920,
"memoryFree": 334184448,
"memoryUsagePercent": 99.02739524841309,
"memoryEfficiency": 0.9726047515869141,
"cpuCount": 12,
"cpuLoad": 0.23970540364583334,
"platform": "darwin",
"uptime": 317541
},
{
"timestamp": 1764953549744,
"memoryTotal": 34359738368,
"memoryUsed": 34073149440,
"memoryFree": 286588928,
"memoryUsagePercent": 99.1659164428711,
"memoryEfficiency": 0.8340835571289062,
"cpuCount": 12,
"cpuLoad": 0.2720133463541667,
"platform": "darwin",
"uptime": 317571
},
{
"timestamp": 1764953579745,
"memoryTotal": 34359738368,
"memoryUsed": 34250440704,
"memoryFree": 109297664,
"memoryUsagePercent": 99.6819019317627,
"memoryEfficiency": 0.3180980682373047,
"cpuCount": 12,
"cpuLoad": 0.2032470703125,
"platform": "darwin",
"uptime": 317601
},
{
"timestamp": 1764953609745,
"memoryTotal": 34359738368,
"memoryUsed": 34292432896,
"memoryFree": 67305472,
"memoryUsagePercent": 99.80411529541016,
"memoryEfficiency": 0.19588470458984375,
"cpuCount": 12,
"cpuLoad": 0.22269694010416666,
"platform": "darwin",
"uptime": 317631
},
{
"timestamp": 1764953639747,
"memoryTotal": 34359738368,
"memoryUsed": 34245033984,
"memoryFree": 114704384,
"memoryUsagePercent": 99.66616630554199,
"memoryEfficiency": 0.3338336944580078,
"cpuCount": 12,
"cpuLoad": 0.1802978515625,
"platform": "darwin",
"uptime": 317661
},
{
"timestamp": 1764953669748,
"memoryTotal": 34359738368,
"memoryUsed": 34262171648,
"memoryFree": 97566720,
"memoryUsagePercent": 99.71604347229004,
"memoryEfficiency": 0.28395652770996094,
"cpuCount": 12,
"cpuLoad": 0.16731770833333334,
"platform": "darwin",
"uptime": 317691
},
{
"timestamp": 1764953699748,
"memoryTotal": 34359738368,
"memoryUsed": 33642708992,
"memoryFree": 717029376,
"memoryUsagePercent": 97.91316986083984,
"memoryEfficiency": 2.0868301391601562,
"cpuCount": 12,
"cpuLoad": 0.1925048828125,
"platform": "darwin",
"uptime": 317721
},
{
"timestamp": 1764953729749,
"memoryTotal": 34359738368,
"memoryUsed": 33480491008,
"memoryFree": 879247360,
"memoryUsagePercent": 97.44105339050293,
"memoryEfficiency": 2.5589466094970703,
"cpuCount": 12,
"cpuLoad": 0.327880859375,
"platform": "darwin",
"uptime": 317751
},
{
"timestamp": 1764953759749,
"memoryTotal": 34359738368,
"memoryUsed": 34072002560,
"memoryFree": 287735808,
"memoryUsagePercent": 99.16257858276367,
"memoryEfficiency": 0.8374214172363281,
"cpuCount": 12,
"cpuLoad": 0.2535400390625,
"platform": "darwin",
"uptime": 317781
},
{
"timestamp": 1764953789750,
"memoryTotal": 34359738368,
"memoryUsed": 34169667584,
"memoryFree": 190070784,
"memoryUsagePercent": 99.44682121276855,
"memoryEfficiency": 0.5531787872314453,
"cpuCount": 12,
"cpuLoad": 0.19832356770833334,
"platform": "darwin",
"uptime": 317811
},
{
"timestamp": 1764953819750,
"memoryTotal": 34359738368,
"memoryUsed": 34144272384,
"memoryFree": 215465984,
"memoryUsagePercent": 99.37291145324707,
"memoryEfficiency": 0.6270885467529297,
"cpuCount": 12,
"cpuLoad": 0.209716796875,
"platform": "darwin",
"uptime": 317841
},
{
"timestamp": 1764953849751,
"memoryTotal": 34359738368,
"memoryUsed": 34237202432,
"memoryFree": 122535936,
"memoryUsagePercent": 99.64337348937988,
"memoryEfficiency": 0.3566265106201172,
"cpuCount": 12,
"cpuLoad": 0.24357096354166666,
"platform": "darwin",
"uptime": 317871
}
]

View file

@ -349,6 +349,15 @@ async function getPaginated(
## Migrations
> **Comprehensive Documentation**: See **[docs/DATABASE_MIGRATIONS.md](/docs/DATABASE_MIGRATIONS.md)** for full migration internals, CI/CD integration, zero-downtime patterns, and troubleshooting.
### Quick Reference
| Environment | Command | Purpose |
| --------------- | ----------------- | ------------------------------- |
| **Development** | `pnpm db:push` | Fast iteration, direct sync |
| **Production** | `pnpm db:migrate` | Tracked migrations with history |
### Configuration
```typescript
@ -358,9 +367,9 @@ import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema/index.ts',
out: './src/db/migrations',
driver: 'pg',
dialect: 'postgresql',
dbCredentials: {
connectionString: process.env.DATABASE_URL!,
url: process.env.DATABASE_URL!,
},
verbose: true,
strict: true,
@ -370,41 +379,85 @@ export default defineConfig({
### Commands
```bash
# Generate migration from schema changes
pnpm drizzle-kit generate
# Development - push schema directly (fast, no history)
pnpm db:push
# Push schema directly (development only)
pnpm drizzle-kit push
# Open Drizzle Studio
pnpm drizzle-kit studio
# Run migrations (production)
# Production - generate and run migrations
pnpm db:generate --name add_user_preferences
pnpm db:migrate
# Open Drizzle Studio for database inspection
pnpm db:studio
```
### Migration Runner
### Migration Workflow
```
┌─────────────────────────────────────────────────────────────────┐
│ Which command should I use? │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Local development? │
│ └── YES → pnpm db:push (fast, no tracking) │
│ │
│ Staging/Production? │
│ └── YES → pnpm db:generate + pnpm db:migrate (tracked) │
│ │
│ Schema changed by someone else? │
│ └── YES → git pull + pnpm db:push (local) │
│ git pull + pnpm db:migrate (staging/prod) │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### Key Concepts
1. **Advisory Locks**: Migrations use PostgreSQL advisory locks to prevent concurrent execution
2. **Migration Tracking**: `__drizzle_migrations` table + `meta/_journal.json` file
3. **Migrations run BEFORE code deployment**: Ensures database is ready for new code
4. **Never modify applied migrations**: Create new migrations instead
5. **Zero-downtime**: Use expand-contract pattern for breaking schema changes
### Production Migration Script
Production backends use a migration script with advisory locks:
```typescript
// src/db/migrate.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import postgres from 'postgres';
// src/db/migrate.ts - Key features:
// - Advisory lock (pg_try_advisory_lock) prevents concurrent migrations
// - Retry logic with exponential backoff for transient failures
// - Timeout protection (default 5 minutes)
// - Graceful handling when no migrations exist
async function runMigrations() {
const connection = postgres(process.env.DATABASE_URL!, { max: 1 });
const db = drizzle(connection);
const MIGRATION_LOCK_ID = 987654321; // Unique per service
console.log('Running migrations...');
await migrate(db, { migrationsFolder: './src/db/migrations' });
console.log('Migrations complete');
await connection.end();
async function acquireLock(db) {
const result = await db.execute(
sql`SELECT pg_try_advisory_lock(${MIGRATION_LOCK_ID}) as acquired`
);
return result[0]?.acquired === true;
}
runMigrations().catch(console.error);
```
See `services/mana-core-auth/src/db/migrate.ts` for the full implementation.
### Best Practices
**DO:**
- Run migrations before deploying new code
- Test migrations in staging before production
- Use `CONCURRENTLY` for index creation
- Keep migrations small and focused
- Commit migration files to version control
**DON'T:**
- Run `db:push` in production
- Delete or modify applied migrations
- Add NOT NULL without default or backfill
- Drop columns immediately (wait 1-2 weeks)
## Query Patterns
### Select with Joins

View file

@ -698,15 +698,138 @@ export default {
## Environment Variables
```typescript
// Access in .svelte or .ts files
import { PUBLIC_BACKEND_URL, PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public';
### Build-Time vs Runtime Variables
// .env file
SvelteKit has **two types** of environment variables:
1. **Build-time** (`$env/static/public`) - Baked into the bundle at build time
2. **Runtime** (`process.env`) - Available at runtime in server code
**CRITICAL**: For Docker deployments, browser-facing URLs must use **runtime injection** because:
- Docker images are built once but deployed to different environments (staging, production)
- Build-time variables would require rebuilding the image for each environment
- The browser cannot access `process.env` - it needs values injected into the HTML
### ❌ WRONG - Hardcoded or Build-Time URLs
```typescript
// ❌ BAD - Hardcoded URL (won't work in Docker)
const MANA_AUTH_URL = 'http://localhost:3001';
// ❌ BAD - Build-time variable (works locally, breaks in Docker)
import { PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public';
const MANA_AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
// ❌ BAD - import.meta.env is also build-time
const MANA_AUTH_URL = import.meta.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
```
### ✅ CORRECT - Runtime Injection Pattern
**Step 1: Create `hooks.server.ts`** to inject env vars into HTML:
```typescript
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
// Get client-side URLs from Docker runtime environment
const PUBLIC_MANA_CORE_AUTH_URL_CLIENT =
process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_CORE_AUTH_URL || '';
const PUBLIC_BACKEND_URL_CLIENT =
process.env.PUBLIC_BACKEND_URL_CLIENT || process.env.PUBLIC_BACKEND_URL || '';
export const handle: Handle = async ({ event, resolve }) => {
return resolve(event, {
transformPageChunk: ({ html }) => {
// Inject runtime environment variables into the HTML
const envScript = `<script>
window.__PUBLIC_MANA_CORE_AUTH_URL__ = "${PUBLIC_MANA_CORE_AUTH_URL_CLIENT}";
window.__PUBLIC_BACKEND_URL__ = "${PUBLIC_BACKEND_URL_CLIENT}";
</script>`;
return html.replace('<head>', `<head>${envScript}`);
},
});
};
```
**Step 2: Read from `window` in client code:**
```typescript
// src/lib/stores/auth.svelte.ts
import { browser } from '$app/environment';
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
// Client-side: use injected window variable
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
// Server-side (SSR): use Docker internal URL
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
}
// Use in auth service initialization
const auth = initializeWebAuth({ baseUrl: getAuthUrl() });
```
**Step 3: Set environment variables in `docker-compose.staging.yml`:**
```yaml
services:
myapp-web:
environment:
# Server-side URLs (Docker internal network)
PUBLIC_BACKEND_URL: http://myapp-backend:3000
PUBLIC_MANA_CORE_AUTH_URL: http://mana-core-auth:3001
# Client-side URLs (browser access via public IP)
PUBLIC_BACKEND_URL_CLIENT: http://46.224.108.214:3000
PUBLIC_MANA_CORE_AUTH_URL_CLIENT: http://46.224.108.214:3001
```
### Why Two URLs?
| Variable | Purpose | Example |
| ---------------------------------- | --------------------------------- | ---------------------------- |
| `PUBLIC_MANA_CORE_AUTH_URL` | Server-to-server (SSR, API calls) | `http://mana-core-auth:3001` |
| `PUBLIC_MANA_CORE_AUTH_URL_CLIENT` | Browser to server | `http://46.224.108.214:3001` |
Docker containers can reach each other by service name (`mana-core-auth`), but browsers need the public IP/domain.
### Apps Using This Pattern Correctly
- ✅ `chat/apps/web` - Has `hooks.server.ts` with runtime injection
- ✅ `todo/apps/web` - Fixed
- ✅ `calendar/apps/web` - Fixed
- ✅ `clock/apps/web` - Fixed
### Apps That Still Need Fixing
- ❌ `contacts/apps/web`
- ❌ `manadeck/apps/web`
- ❌ `manacore/apps/web`
- ❌ `zitare/apps/web`
- ❌ `picture/apps/web`
### Quick Checklist for New SvelteKit Apps
- [ ] Create `src/hooks.server.ts` with env injection
- [ ] Update `auth.svelte.ts` to use `getAuthUrl()` pattern
- [ ] Update `user-settings.svelte.ts` to use `getAuthUrl()` pattern
- [ ] Update any feedback services to use runtime URL
- [ ] Add both `_CLIENT` and non-client env vars to `docker-compose.staging.yml`
- [ ] Never hardcode `localhost:3001` anywhere
### Simple .env (for local development only)
```env
PUBLIC_BACKEND_URL=http://localhost:3016
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
```
These work locally because both the browser and server access `localhost`.
## Anti-Patterns to Avoid
### Don't Use Old Svelte Syntax

View file

@ -1,3 +1,10 @@
# Production Deployment
#
# Triggered by:
# - Manual only (workflow_dispatch with confirmation)
#
# Flow: dev (staging) → main (production)
# Requires typing "deploy" to confirm
name: CD - Production Deployment
on:
@ -205,8 +212,52 @@ jobs:
ssh ${{ secrets.PRODUCTION_USER }}@${{ secrets.PRODUCTION_HOST }} << 'EOF'
cd ~/manacore-production
# Run migrations before deploying new code
docker compose run --rm mana-core-auth pnpm run db:migrate || echo "Migrations completed or skipped"
echo "=== Running Database Migrations ==="
echo ""
# Migration function with retry logic
run_migration() {
local service=$1
local max_attempts=3
local timeout=300 # 5 minutes
local attempt=1
while [ $attempt -le $max_attempts ]; do
echo "[$service] Migration attempt $attempt/$max_attempts..."
# Run migration with timeout using a temporary container
if timeout $timeout docker compose run --rm $service pnpm run db:migrate 2>&1; then
echo "✅ [$service] Migration succeeded"
return 0
else
exit_code=$?
if [ $exit_code -eq 124 ]; then
echo "⚠️ [$service] Migration timeout after ${timeout}s"
else
echo "⚠️ [$service] Migration failed with exit code $exit_code"
fi
attempt=$((attempt + 1))
if [ $attempt -le $max_attempts ]; then
wait_time=$((10 * attempt)) # Backoff: 10s, 20s, 30s
echo " Waiting ${wait_time}s before retry..."
sleep $wait_time
fi
fi
done
echo "❌ [$service] Migration failed after $max_attempts attempts"
return 1
}
# Run migrations for mana-core-auth (central auth service)
run_migration mana-core-auth || {
echo "❌ mana-core-auth migration failed"
echo "⚠️ Continuing with deployment - manual migration may be required"
}
echo ""
echo "✅ Migration step completed"
EOF
- name: Deploy with zero-downtime

View file

@ -21,6 +21,10 @@ on:
- zitare
- presi
- mana-core-auth
- manacore
- todo
- calendar
- clock
apps:
description: 'Apps to deploy (comma-separated: backend,web,landing or "all")'
required: true
@ -36,7 +40,8 @@ env:
NODE_VERSION: '20'
PNPM_VERSION: '9.15.0'
REGISTRY: ghcr.io
IMAGE_PREFIX: ghcr.io/${{ github.repository_owner }}
# Note: repository_owner is lowercased for Docker compatibility
IMAGE_PREFIX: ghcr.io/memo-2023
jobs:
# Parse tag or inputs to determine what to deploy
@ -104,6 +109,10 @@ jobs:
PROJECT_APPS[zitare]="backend,web"
PROJECT_APPS[presi]="backend,web"
PROJECT_APPS[mana-core-auth]="service"
PROJECT_APPS[manacore]="web"
PROJECT_APPS[todo]="backend,web"
PROJECT_APPS[calendar]="backend,web"
PROJECT_APPS[clock]="backend,web"
# Expand "all" to available apps
if [ "$APPS" == "all" ]; then
@ -135,8 +144,17 @@ jobs:
IMAGE_NAME="${PROJECT}-backend"
;;
web)
DOCKERFILE_PATH="docker/templates/Dockerfile.sveltekit"
CONTEXT_PATH="apps/$PROJECT/apps/web"
# Apps with their own Dockerfiles (need monorepo root for shared packages)
case "$PROJECT" in
manacore|todo|calendar|clock)
DOCKERFILE_PATH="apps/$PROJECT/apps/web/Dockerfile"
CONTEXT_PATH="."
;;
*)
DOCKERFILE_PATH="docker/templates/Dockerfile.sveltekit"
CONTEXT_PATH="apps/$PROJECT/apps/web"
;;
esac
IMAGE_NAME="${PROJECT}-web"
;;
landing)
@ -146,15 +164,29 @@ jobs:
;;
esac
# Set ports per project
# Set backend ports per project (must match docker-compose.staging.yml)
case "$PROJECT" in
chat) PORT="3002" ;;
picture) PORT="3006" ;;
manadeck) PORT="3009" ;;
zitare) PORT="3007" ;;
presi) PORT="3008" ;;
calendar) PORT="3016" ;;
clock) PORT="3017" ;;
todo) PORT="3018" ;;
esac
HEALTH_PATH="/api/health"
# Override ports for web apps (SvelteKit uses different ports)
if [ "$APP" == "web" ]; then
case "$PROJECT" in
manacore) PORT="5173" ;;
todo) PORT="5188" ;;
calendar) PORT="5186" ;;
clock) PORT="5187" ;;
*) PORT="5173" ;; # default SvelteKit port
esac
fi
HEALTH_PATH="/api/v1/health"
;;
esac
@ -267,25 +299,71 @@ jobs:
mkdir -p ~/.ssh
ssh-keyscan -H ${{ secrets.STAGING_HOST }} >> ~/.ssh/known_hosts
- name: Sync docker-compose to staging
run: |
# Ensure staging directory exists
ssh ${{ secrets.STAGING_USER }}@${{ secrets.STAGING_HOST }} "mkdir -p ~/manacore-staging"
# Copy the docker-compose file
scp docker-compose.staging.yml ${{ secrets.STAGING_USER }}@${{ secrets.STAGING_HOST }}:~/manacore-staging/docker-compose.yml
- name: Login to GHCR on staging server
run: |
ssh ${{ secrets.STAGING_USER }}@${{ secrets.STAGING_HOST }} << EOF
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
EOF
- name: Deploy service
env:
VERSION: ${{ needs.parse-deployment.outputs.version }}
IMAGE_NAME: ${{ matrix.image_name }}
APP_TYPE: ${{ matrix.app }}
PROJECT: ${{ needs.parse-deployment.outputs.project }}
run: |
# Compute the version variable name locally (before SSH)
# Map: todo-web -> TODO_WEB_VERSION, chat-backend -> CHAT_VERSION
case "$IMAGE_NAME" in
*-web)
PROJECT_UPPER=$(echo "$PROJECT" | tr '[:lower:]-' '[:upper:]_')
VERSION_VAR="${PROJECT_UPPER}_WEB_VERSION"
;;
*-backend)
PROJECT_UPPER=$(echo "$PROJECT" | tr '[:lower:]-' '[:upper:]_')
VERSION_VAR="${PROJECT_UPPER}_VERSION"
;;
mana-core-auth)
VERSION_VAR="AUTH_VERSION"
;;
*)
VERSION_VAR=$(echo "$IMAGE_NAME" | tr '[:lower:]-' '[:upper:]_')_VERSION
;;
esac
echo "Will set $VERSION_VAR=$VERSION for docker-compose"
ssh ${{ secrets.STAGING_USER }}@${{ secrets.STAGING_HOST }} << EOF
cd ~/manacore-staging
echo "Deploying $IMAGE_NAME:$VERSION to staging..."
# Pull the new image
# Pull the new image with specific version tag
docker pull ${{ env.IMAGE_PREFIX }}/$IMAGE_NAME:$VERSION
# Restart only the specific service
SERVICE_NAME=\$(echo "$IMAGE_NAME" | tr '-' '_')
# Update .env file with the version for this service
# This ensures docker-compose uses the correct image tag
if grep -q "^$VERSION_VAR=" .env 2>/dev/null; then
sed -i "s/^$VERSION_VAR=.*/$VERSION_VAR=$VERSION/" .env
else
echo "$VERSION_VAR=$VERSION" >> .env
fi
if docker compose ps | grep -q "\$SERVICE_NAME"; then
echo "Updated .env: $VERSION_VAR=$VERSION"
grep "$VERSION_VAR" .env || true
# Service name matches docker-compose service name (with hyphens)
SERVICE_NAME="$IMAGE_NAME"
if docker compose ps -a | grep -q "$IMAGE_NAME"; then
echo "Updating existing service: \$SERVICE_NAME"
docker compose pull \$SERVICE_NAME || true
docker compose up -d --no-deps --force-recreate \$SERVICE_NAME
else
echo "Service \$SERVICE_NAME not found in compose, starting..."
@ -296,6 +374,10 @@ jobs:
sleep 10
docker compose ps \$SERVICE_NAME
# Verify correct image is running
echo "Running image:"
docker inspect --format='{{.Config.Image}}' ${IMAGE_NAME}-staging 2>/dev/null || true
# Cleanup old images
docker image prune -f
EOF

View file

@ -1,11 +1,13 @@
# Simplified staging config: mana-core-auth + chat-backend only
# Staging Deployment
#
# Triggered by:
# - Automatic: Push to dev branch (via ci.yml)
# - Manual: workflow_dispatch
#
# Full config archived at: .github/workflows/cd-staging.full.yml
#
# To restore full config:
# cp .github/workflows/cd-staging.full.yml .github/workflows/cd-staging.yml
#
# To add a service back:
# 1. Add service to workflow_dispatch options (line ~10)
# To add a service:
# 1. Add service to workflow_dispatch options
# 2. Add health check in "Run health checks" step
# 3. Add service to docker-compose.staging.yml
name: CD - Staging Deployment
@ -22,6 +24,13 @@ on:
- mana-core-auth
- chat-backend
- chat-web
- manacore-web
- todo-backend
- todo-web
- calendar-backend
- calendar-web
- clock-backend
- clock-web
workflow_call:
permissions:
@ -182,9 +191,81 @@ jobs:
# Create chat database (for chat-backend service)
docker compose exec -T postgres psql -U postgres -c "CREATE DATABASE chat;" 2>/dev/null || echo "chat database already exists"
# Create todo database (for todo-backend service)
docker compose exec -T postgres psql -U postgres -c "CREATE DATABASE todo;" 2>/dev/null || echo "todo database already exists"
# Create calendar database (for calendar-backend service)
docker compose exec -T postgres psql -U postgres -c "CREATE DATABASE calendar;" 2>/dev/null || echo "calendar database already exists"
# Create clock database (for clock-backend service)
docker compose exec -T postgres psql -U postgres -c "CREATE DATABASE clock;" 2>/dev/null || echo "clock database already exists"
echo "✅ Databases ready"
EOF
- name: Run database migrations
env:
STAGING_USER: deploy
STAGING_HOST: 46.224.108.214
run: |
ssh $STAGING_USER@$STAGING_HOST << 'EOF'
cd ~/manacore-staging
echo "=== Running Database Migrations ==="
echo ""
# Migration function with retry logic
run_migration() {
local service=$1
local max_attempts=3
local timeout=300 # 5 minutes
local attempt=1
while [ $attempt -le $max_attempts ]; do
echo "[$service] Migration attempt $attempt/$max_attempts..."
# Run migration with timeout
if timeout $timeout docker compose exec -T $service pnpm run db:migrate 2>&1; then
echo "✅ [$service] Migration succeeded"
return 0
else
exit_code=$?
if [ $exit_code -eq 124 ]; then
echo "⚠️ [$service] Migration timeout after ${timeout}s"
else
echo "⚠️ [$service] Migration failed with exit code $exit_code"
fi
attempt=$((attempt + 1))
if [ $attempt -le $max_attempts ]; then
wait_time=$((10 * attempt)) # Backoff: 10s, 20s, 30s
echo " Waiting ${wait_time}s before retry..."
sleep $wait_time
fi
fi
done
echo "❌ [$service] Migration failed after $max_attempts attempts"
return 1
}
# Run migrations for services that have db:migrate script
# mana-core-auth - central auth service
if docker compose exec -T mana-core-auth test -f src/db/migrate.ts 2>/dev/null || \
docker compose exec -T mana-core-auth pnpm run db:migrate --help 2>/dev/null; then
run_migration mana-core-auth || {
echo "❌ mana-core-auth migration failed - aborting deployment"
exit 1
}
else
echo "⏭️ [mana-core-auth] No db:migrate script, using db:push..."
docker compose exec -T mana-core-auth npx drizzle-kit push --force || echo "Auth schema push completed"
fi
echo ""
echo "✅ All migrations completed"
EOF
- name: Run health checks
env:
STAGING_USER: deploy
@ -193,66 +274,69 @@ jobs:
ssh $STAGING_USER@$STAGING_HOST << 'EOF'
cd ~/manacore-staging
# Wait for services to fully start
echo "Waiting 60s for services to fully initialize..."
sleep 60
echo "=== Health Checks with Polling ==="
echo ""
# Health check function with retry polling
check_health() {
local service=$1
local url=$2
local max_attempts=24 # 24 * 5s = 2 minutes max wait
local attempt=1
echo "Checking $service..."
while [ $attempt -le $max_attempts ]; do
# Check if container is running
if ! docker compose ps $service 2>/dev/null | grep -q "Up"; then
if [ $attempt -eq 1 ]; then
echo " ⏳ Waiting for container to start..."
fi
sleep 5
attempt=$((attempt + 1))
continue
fi
# Check health endpoint
if docker compose exec -T $service wget -q -O - $url > /dev/null 2>&1; then
echo " ✅ $service is healthy (attempt $attempt)"
return 0
fi
if [ $attempt -eq 1 ]; then
echo " ⏳ Waiting for $service to become healthy..."
fi
sleep 5
attempt=$((attempt + 1))
done
echo " ❌ $service health check failed after $max_attempts attempts"
echo " === Recent Logs ==="
docker compose logs --tail=50 $service
return 1
}
echo "=== Container Status ==="
docker compose ps
echo ""
echo "=== Health Checks ==="
# Check mana-core-auth
echo "Checking mana-core-auth..."
if docker compose exec -T mana-core-auth wget -q -O - http://localhost:3001/api/v1/health > /dev/null 2>&1; then
echo "✅ mana-core-auth is healthy"
else
echo "❌ mana-core-auth health check failed"
echo "=== Logs ==="
docker compose logs --tail=50 mana-core-auth
exit 1
fi
# Check chat-backend
echo "Checking chat-backend..."
if docker compose exec -T chat-backend wget -q -O - http://localhost:3002/api/v1/health > /dev/null 2>&1; then
echo "✅ chat-backend is healthy"
else
echo "❌ chat-backend health check failed"
echo "=== Logs ==="
docker compose logs --tail=50 chat-backend
exit 1
fi
# Check chat-web
echo "Checking chat-web..."
if docker compose exec -T chat-web wget -q -O - http://localhost:3000/health > /dev/null 2>&1; then
echo "✅ chat-web is healthy"
else
echo "❌ chat-web health check failed"
echo "=== Logs ==="
docker compose logs --tail=50 chat-web
exit 1
fi
# Check all services with polling
check_health mana-core-auth http://localhost:3001/api/v1/health || exit 1
check_health chat-backend http://localhost:3002/api/v1/health || exit 1
check_health chat-web http://localhost:3000/health || exit 1
check_health manacore-web http://localhost:5173/health || exit 1
check_health todo-backend http://localhost:3018/api/v1/health || exit 1
check_health todo-web http://localhost:5188/health || exit 1
check_health calendar-backend http://localhost:3016/api/v1/health || exit 1
check_health calendar-web http://localhost:5186/health || exit 1
check_health clock-backend http://localhost:3017/api/v1/health || exit 1
check_health clock-web http://localhost:5187/health || exit 1
echo ""
echo "✅ All health checks passed!"
EOF
- name: Run database migrations
env:
STAGING_USER: deploy
STAGING_HOST: 46.224.108.214
run: |
# Run migrations for services that need them
ssh $STAGING_USER@$STAGING_HOST << 'EOF'
cd ~/manacore-staging
# Mana Core Auth - push schema using Drizzle (--force skips interactive confirmation)
docker compose exec -T mana-core-auth npx drizzle-kit push --force || echo "Auth schema push skipped"
EOF
- name: Deployment summary
run: |
echo "## Staging Deployment Summary" >> $GITHUB_STEP_SUMMARY

View file

@ -1,31 +1,98 @@
# MINIMAL: Only builds mana-core-auth + chat Docker images, no validation
# Full config archived at: .github/workflows/ci-main.full.yml
# CI Pipeline: Validates code on PRs, builds images on push
#
# To restore: cp .github/workflows/ci-main.full.yml .github/workflows/ci-main.yml
# Flow:
# PR → dev/main : Runs validation (required status check)
# Push → dev/main : Builds Docker images (NO auto-deploy)
#
# Deployments are triggered separately:
# - Manual: workflow_dispatch on cd-staging.yml / cd-production.yml
# - Tag-based: push tag like "chat-staging-v1.0.0" triggers cd-staging-tagged.yml
#
# Full config archived at: .github/workflows/ci-main.full.yml
name: CI - Main Branch
name: CI
on:
push:
branches:
- main
- dev
paths:
- 'apps/**'
- 'packages/**'
- 'services/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'turbo.json'
pull_request:
branches:
- main
- dev
paths:
- 'apps/**'
- 'packages/**'
- 'services/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'turbo.json'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
NODE_VERSION: '20'
PNPM_VERSION: '9.15.0'
jobs:
# Build Docker images directly - Dockerfiles handle their own dependencies
# Validation job - runs on PRs to catch issues before merge
validate:
name: Validate
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: ${{ env.PNPM_VERSION }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Type check
run: pnpm run type-check
- name: Lint
run: pnpm run lint || echo "Lint warnings found"
# Build Docker images - only on push to dev/main (not PRs)
build-docker-images:
name: Build ${{ matrix.service.name }}
runs-on: ubuntu-latest
if: github.event_name == 'push'
strategy:
matrix:
service:
- { name: 'mana-core-auth', path: 'services/mana-core-auth', port: '3001' }
- { name: 'chat-backend', path: 'apps/chat/apps/backend', port: '3002' }
- { name: 'chat-web', path: 'apps/chat/apps/web', port: '3000' }
- { name: 'manacore-web', path: 'apps/manacore/apps/web', port: '5173' }
- { name: 'todo-backend', path: 'apps/todo/apps/backend', port: '3018' }
- { name: 'todo-web', path: 'apps/todo/apps/web', port: '5188' }
- { name: 'calendar-backend', path: 'apps/calendar/apps/backend', port: '3016' }
- { name: 'calendar-web', path: 'apps/calendar/apps/web', port: '5186' }
- { name: 'clock-backend', path: 'apps/clock/apps/backend', port: '3017' }
- { name: 'clock-web', path: 'apps/clock/apps/web', port: '5187' }
fail-fast: false
steps:
- name: Checkout code
@ -73,20 +140,3 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
# Trigger staging deployment after all images are built
deploy-staging:
name: Deploy to Staging
runs-on: ubuntu-latest
needs: build-docker-images
steps:
- name: Trigger staging deployment
uses: actions/github-script@v7
with:
script: |
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'cd-staging.yml',
ref: 'main'
});
console.log('Staging deployment triggered');

1
.gitignore vendored
View file

@ -95,3 +95,4 @@ yarn.lock
# Claude Flow metrics
.claude-flow/
.claude-flow/metrics/

View file

@ -60,19 +60,46 @@ These projects are temporarily archived and excluded from the workspace. To re-a
## Development Commands
```bash
# Install dependencies
pnpm install
For detailed local development setup, see **[docs/LOCAL_DEVELOPMENT.md](docs/LOCAL_DEVELOPMENT.md)**.
### Quick Start (Recommended)
Use `dev:*:full` commands to start any app with automatic database setup:
```bash
pnpm docker:up # Start PostgreSQL, Redis, MinIO
pnpm dev:chat:full # Start chat with auth + auto DB setup
pnpm dev:zitare:full # Start zitare with auth + auto DB setup
pnpm dev:contacts:full # Start contacts with auth + auto DB setup
pnpm dev:calendar:full # Start calendar with auth + auto DB setup
pnpm dev:clock:full # Start clock with auth + auto DB setup
pnpm dev:todo:full # Start todo with auth + auto DB setup
pnpm dev:picture:full # Start picture with auth + auto DB setup
```
These commands automatically:
1. Create the database if missing
2. Push the latest schema
3. Start auth, backend, and web with colored output
### Database Setup
```bash
pnpm setup:db # Setup ALL databases and schemas
pnpm setup:db:chat # Setup just chat
pnpm setup:db:auth # Setup just auth
```
### Individual App Commands
```bash
# Start specific project (runs all apps in project)
pnpm run manacore:dev
pnpm run manadeck:dev
pnpm run picture:dev
pnpm run chat:dev
pnpm run zitare:dev
pnpm run presi:dev
pnpm run contacts:dev
pnpm run mail:dev
# Start specific app within project
pnpm run dev:chat:mobile # Just mobile app
@ -607,7 +634,9 @@ PORT=...
## Project-Specific Documentation
- **[docs/LOCAL_DEVELOPMENT.md](docs/LOCAL_DEVELOPMENT.md)** - Database setup and `dev:*:full` commands
- **[docs/ENVIRONMENT_VARIABLES.md](docs/ENVIRONMENT_VARIABLES.md)** - Complete environment setup guide
- **[docs/DATABASE_MIGRATIONS.md](docs/DATABASE_MIGRATIONS.md)** - Migration best practices, CI/CD, rollback procedures
Each project has its own `CLAUDE.md` with detailed information:

View file

@ -16,6 +16,8 @@ Common issues and solutions for the manacore-monorepo.
- [Client-Side Calling localhost Instead of Public IP](#problem-5-client-side-calling-localhost-instead-of-public-ip)
- [CORS Blocking Cross-Origin Requests](#problem-6-cors-blocking-cross-origin-requests)
- [Missing Database Schema](#problem-7-missing-database-schema)
- [pnpm Symlinks Broken in Docker Container](#problem-8-pnpm-symlinks-broken-in-docker-container)
- [Hardcoded localhost URLs in SvelteKit Web Apps](#problem-9-hardcoded-localhost-urls-in-sveltekit-web-apps)
---
@ -922,6 +924,229 @@ docker compose exec -T chat-backend wget -q -O - http://localhost:3002/api/v1/he
12. **Drizzle Kit Interactive Mode:** `drizzle-kit push` prompts for confirmation. Use `--force` flag in CI/CD to skip interactive mode.
13. **pnpm Symlinks in Docker:** pnpm uses symlinks to a central `.pnpm` store. When copying `node_modules` in Docker, you must preserve both the symlinks AND the target directory they point to. See [Problem 8](#problem-8-pnpm-symlinks-broken-in-docker-container).
---
### Problem 8: pnpm Symlinks Broken in Docker Container
**Symptoms:**
- Container starts but crashes with: `Cannot find package 'date-fns' imported from /app/build/server/chunks/_page.svelte-xxx.js`
- Error `ERR_MODULE_NOT_FOUND` for packages that ARE in node_modules
- Works locally but fails in Docker production stage
- `ls node_modules/date-fns` shows a symlink pointing to `../../../../../node_modules/.pnpm/...`
**Root Cause:**
pnpm uses symlinks to a central `.pnpm` store at the monorepo root. When you copy only the app's `node_modules` in Docker, the symlinks point to paths that don't exist:
```
# In builder stage (pnpm workspace):
/app/apps/todo/apps/web/node_modules/date-fns → ../../../../../node_modules/.pnpm/date-fns@4.1.0/node_modules/date-fns
# In production stage (old broken approach):
/app/node_modules/date-fns → ../../../../../node_modules/.pnpm/...
# ↑ BROKEN! This path doesn't exist because we only copied to /app/
```
**❌ WRONG - Flattening Directory Structure:**
```dockerfile
# Production stage
FROM node:20-alpine AS production
WORKDIR /app # ❌ Different from builder structure
# Copy node_modules (symlinks will be broken!)
COPY --from=builder /app/apps/todo/apps/web/node_modules ./node_modules # ❌ BROKEN
COPY --from=builder /app/apps/todo/apps/web/build ./build
COPY --from=builder /app/apps/todo/apps/web/package.json ./
CMD ["node", "build"]
```
The symlinks in `node_modules` point to `../../../../../node_modules/.pnpm/...` which resolves to a non-existent path from `/app/`.
**✅ CORRECT - Preserve Directory Structure + Copy .pnpm Store:**
```dockerfile
# Production stage
FROM node:20-alpine AS production
# Keep same directory structure as builder so pnpm symlinks resolve correctly
WORKDIR /app/apps/todo/apps/web # ✅ Same as builder
# Copy the pnpm store that symlinks point to (at /app/node_modules/.pnpm)
COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm # ✅ Target of symlinks
# Copy the app's node_modules (contains symlinks to the pnpm store)
COPY --from=builder /app/apps/todo/apps/web/node_modules ./node_modules # ✅ Symlinks work now
# Copy built application
COPY --from=builder /app/apps/todo/apps/web/build ./build
COPY --from=builder /app/apps/todo/apps/web/package.json ./
CMD ["node", "build"]
```
**Why This Works:**
1. `WORKDIR /app/apps/todo/apps/web` - Production container has same path as builder
2. Symlinks in `./node_modules/` point to `../../../../../node_modules/.pnpm/...`
3. From `/app/apps/todo/apps/web/node_modules/`, going up 5 directories reaches `/app/`
4. `/app/node_modules/.pnpm/` exists because we copied it!
**How to Debug:**
```bash
# Check if symlinks are broken in the container
docker exec <container> ls -la node_modules/date-fns
# Shows: date-fns -> ../../../../../node_modules/.pnpm/date-fns@4.1.0/node_modules/date-fns
# Check if the target exists
docker exec <container> ls -la /app/node_modules/.pnpm/date-fns@4.1.0/
# If "No such file or directory" → symlink is broken
# Check the image size (should be ~1GB with .pnpm store, ~50MB without)
docker images | grep todo-web
```
**Trade-offs:**
| Approach | Image Size | Symlinks | Works |
| ------------------------------------- | ---------- | -------- | ----- |
| Copy only app's node_modules | ~50MB | Broken | ❌ |
| Copy app's node_modules + .pnpm store | ~1GB | Working | ✅ |
The larger image size is the cost of pnpm's deduplication strategy. In a monorepo, this is actually more efficient than copying all dependencies flat.
**Alternative: Use npm Instead of pnpm in Docker:**
If image size is critical, you could use npm in the Docker build:
```dockerfile
# Alternative approach (not recommended for monorepos)
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=builder /app/apps/todo/apps/web/build ./build
COPY --from=builder /app/apps/todo/apps/web/package.json ./
# Clean install with npm (flattens dependencies)
RUN npm install --omit=dev
CMD ["node", "build"]
```
⚠️ **Warning:** This may fail with `workspace:*` protocol in package.json dependencies. Only works if all dependencies are published to npm.
**Affected Files:**
- `apps/todo/apps/web/Dockerfile`
- `apps/manacore/apps/web/Dockerfile`
- `apps/chat/apps/web/Dockerfile`
- `apps/calendar/apps/web/Dockerfile`
- `apps/clock/apps/web/Dockerfile`
**Related Commits:**
- `fd1c0ee6` - fix(docker): preserve pnpm symlink structure in web Dockerfiles
---
### Problem 9: Hardcoded localhost URLs in SvelteKit Web Apps
**Symptoms:**
- Browser console shows: `POST http://localhost:3001/api/v1/auth/login net::ERR_CONNECTION_REFUSED`
- App works locally but auth fails on staging/production
- The `window.__PUBLIC_MANA_CORE_AUTH_URL__` may be set correctly, but code doesn't use it
- Looking at the source code reveals hardcoded URLs like `const MANA_AUTH_URL = 'http://localhost:3001'`
**Root Cause:**
Developers hardcode `localhost:3001` directly in TypeScript files instead of using the runtime injection pattern. This works locally but breaks in Docker deployments.
**Common Locations of Hardcoded URLs:**
```typescript
// ❌ These patterns are WRONG:
// In auth.svelte.ts
const MANA_AUTH_URL = 'http://localhost:3001';
// In user-settings.svelte.ts
const MANA_AUTH_URL = 'http://localhost:3001';
// In feedback.ts or feedback page
apiUrl: 'http://localhost:3001',
// Using build-time env vars (also wrong for Docker)
const MANA_AUTH_URL = import.meta.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
```
**Solution:**
1. **Create `hooks.server.ts`** if it doesn't exist (see Problem 5)
2. **Use `getAuthUrl()` pattern in all files:**
```typescript
// ✅ CORRECT pattern
import { browser } from '$app/environment';
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
}
// Use getAuthUrl() instead of hardcoded string
const auth = initializeWebAuth({ baseUrl: getAuthUrl() });
```
**How to Find Hardcoded URLs:**
```bash
# Search for hardcoded localhost:3001 in web apps
grep -r "localhost:3001" apps/*/apps/web/src --include="*.ts" --include="*.svelte"
# Check for the correct pattern (window injection)
grep -r "__PUBLIC_MANA_CORE_AUTH_URL__" apps/*/apps/web/src
```
**Apps Status (as of 2024-12):**
| App | Status | Files to Fix |
| ------------------- | ------------ | ---------------------------------------------------------------- |
| `chat/apps/web` | ✅ Fixed | - |
| `todo/apps/web` | ✅ Fixed | - |
| `calendar/apps/web` | ✅ Fixed | - |
| `clock/apps/web` | ✅ Fixed | - |
| `contacts/apps/web` | ❌ Needs Fix | auth.svelte.ts, user-settings.svelte.ts, feedback.ts |
| `manadeck/apps/web` | ❌ Needs Fix | user-settings.svelte.ts, feedback.ts |
| `manacore/apps/web` | ❌ Needs Fix | auth.svelte.ts, user-settings.svelte.ts, feedback.ts, credits.ts |
| `zitare/apps/web` | ❌ Needs Fix | auth.svelte.ts, user-settings.svelte.ts, feedback.ts |
| `picture/apps/web` | ❌ Needs Fix | user-settings.svelte.ts |
**Complete Fix Checklist for Each App:**
- [ ] Create/update `src/hooks.server.ts` with env injection
- [ ] Update `src/lib/stores/auth.svelte.ts` → use `getAuthUrl()` pattern
- [ ] Update `src/lib/stores/user-settings.svelte.ts` → use `getAuthUrl()` pattern
- [ ] Update any `feedback.ts` or feedback services → use `getAuthUrl()` pattern
- [ ] Update any other files with hardcoded URLs
- [ ] Add `PUBLIC_MANA_CORE_AUTH_URL_CLIENT` to `docker-compose.staging.yml`
- [ ] Test locally with `pnpm dev`
- [ ] Deploy and test on staging
**See Also:**
- [Problem 5: Client-Side Calling localhost Instead of Public IP](#problem-5-client-side-calling-localhost-instead-of-public-ip)
- [SvelteKit Web Guidelines - Environment Variables](/.claude/guidelines/sveltekit-web.md#environment-variables)
---
## References

View file

@ -0,0 +1,68 @@
# Build stage
FROM node:20-alpine AS builder
# Install pnpm
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
WORKDIR /app
# Copy root workspace files
COPY pnpm-workspace.yaml ./
COPY package.json ./
COPY pnpm-lock.yaml ./
# Copy shared packages
COPY packages/shared-errors ./packages/shared-errors
COPY packages/shared-nestjs-auth ./packages/shared-nestjs-auth
# Copy calendar packages and backend
COPY apps/calendar/packages ./apps/calendar/packages
COPY apps/calendar/apps/backend ./apps/calendar/apps/backend
# Install dependencies
RUN pnpm install --frozen-lockfile
# Build shared packages first
WORKDIR /app/packages/shared-errors
RUN pnpm build
WORKDIR /app/packages/shared-nestjs-auth
RUN pnpm build
# Build the backend
WORKDIR /app/apps/calendar/apps/backend
RUN pnpm build
# Production stage
FROM node:20-alpine AS production
# Install pnpm and postgresql-client for health checks
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate \
&& apk add --no-cache postgresql-client
WORKDIR /app
# Copy everything from builder (including node_modules)
COPY --from=builder /app/pnpm-workspace.yaml ./
COPY --from=builder /app/package.json ./
COPY --from=builder /app/pnpm-lock.yaml ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/packages ./packages
COPY --from=builder /app/apps/calendar ./apps/calendar
# Copy entrypoint script
COPY apps/calendar/apps/backend/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
WORKDIR /app/apps/calendar/apps/backend
# Expose port
EXPOSE 3016
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3016/api/v1/health || exit 1
# Run entrypoint script
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "dist/main.js"]

View file

@ -0,0 +1,23 @@
#!/bin/sh
set -e
echo "=== Calendar Backend Entrypoint ==="
# Wait for PostgreSQL to be ready
echo "Waiting for PostgreSQL..."
until pg_isready -h ${DB_HOST:-postgres} -p ${DB_PORT:-5432} -U ${DB_USER:-postgres} 2>/dev/null; do
echo "PostgreSQL is unavailable - sleeping"
sleep 2
done
echo "PostgreSQL is up!"
cd /app/apps/calendar/apps/backend
# Run schema push
echo "Pushing database schema..."
npx drizzle-kit push --force
echo "Schema push completed!"
# Execute the main command
echo "Starting application..."
exec "$@"

View file

@ -0,0 +1,84 @@
# Build stage
FROM node:20-alpine AS builder
# Build arguments for SvelteKit static env vars
ARG PUBLIC_BACKEND_URL=http://calendar-backend:3016
ARG PUBLIC_MANA_CORE_AUTH_URL=http://mana-core-auth:3001
# Set as environment variables for build
ENV PUBLIC_BACKEND_URL=$PUBLIC_BACKEND_URL
ENV PUBLIC_MANA_CORE_AUTH_URL=$PUBLIC_MANA_CORE_AUTH_URL
# Install pnpm
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
WORKDIR /app
# Copy root workspace files
COPY pnpm-workspace.yaml ./
COPY package.json ./
COPY pnpm-lock.yaml ./
# Copy shared packages needed by calendar web
COPY packages/shared-auth ./packages/shared-auth
COPY packages/shared-auth-ui ./packages/shared-auth-ui
COPY packages/shared-branding ./packages/shared-branding
COPY packages/shared-feedback-service ./packages/shared-feedback-service
COPY packages/shared-feedback-types ./packages/shared-feedback-types
COPY packages/shared-feedback-ui ./packages/shared-feedback-ui
COPY packages/shared-i18n ./packages/shared-i18n
COPY packages/shared-icons ./packages/shared-icons
COPY packages/shared-tailwind ./packages/shared-tailwind
COPY packages/shared-theme ./packages/shared-theme
COPY packages/shared-theme-ui ./packages/shared-theme-ui
COPY packages/shared-subscription-types ./packages/shared-subscription-types
COPY packages/shared-subscription-ui ./packages/shared-subscription-ui
COPY packages/shared-profile-ui ./packages/shared-profile-ui
COPY packages/shared-ui ./packages/shared-ui
COPY packages/shared-utils ./packages/shared-utils
# Copy calendar packages and web
COPY apps/calendar/packages ./apps/calendar/packages
COPY apps/calendar/apps/web ./apps/calendar/apps/web
# Install dependencies
RUN pnpm install --frozen-lockfile
# Build shared packages that need building
WORKDIR /app/packages/shared-auth
RUN pnpm build || true
# Build the web app
WORKDIR /app/apps/calendar/apps/web
RUN pnpm build
# Production stage
FROM node:20-alpine AS production
# Keep same directory structure as builder so pnpm symlinks resolve correctly
WORKDIR /app/apps/calendar/apps/web
# Copy the pnpm store that symlinks point to (at /app/node_modules/.pnpm)
COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm
# Copy the app's node_modules (contains symlinks to the pnpm store)
COPY --from=builder /app/apps/calendar/apps/web/node_modules ./node_modules
# Copy built application
COPY --from=builder /app/apps/calendar/apps/web/build ./build
COPY --from=builder /app/apps/calendar/apps/web/package.json ./
# Expose port
EXPOSE 5186
# Set environment variables
ENV NODE_ENV=production
ENV PORT=5186
ENV HOST=0.0.0.0
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:5186/health || exit 1
# Run the app
CMD ["node", "build"]

View file

@ -13,7 +13,7 @@
"type-check": "echo 'Skipping type-check for now'"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/adapter-node": "^5.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.1.7",

View file

@ -0,0 +1,27 @@
/**
* Server Hooks for SvelteKit
* - Injects runtime environment variables for client-side use
* - Auth is handled client-side via Mana Core Auth
*/
import type { Handle } from '@sveltejs/kit';
// Get client-side URLs from environment (Docker runtime)
const PUBLIC_MANA_CORE_AUTH_URL_CLIENT =
process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_CORE_AUTH_URL || '';
const PUBLIC_BACKEND_URL_CLIENT =
process.env.PUBLIC_BACKEND_URL_CLIENT || process.env.PUBLIC_BACKEND_URL || '';
export const handle: Handle = async ({ event, resolve }) => {
return resolve(event, {
transformPageChunk: ({ html }) => {
// Inject runtime environment variables into the HTML
// These will be available on window.__PUBLIC_*__ for client-side code
const envScript = `<script>
window.__PUBLIC_MANA_CORE_AUTH_URL__ = "${PUBLIC_MANA_CORE_AUTH_URL_CLIENT}";
window.__PUBLIC_BACKEND_URL__ = "${PUBLIC_BACKEND_URL_CLIENT}";
</script>`;
return html.replace('<head>', `<head>${envScript}`);
},
});
};

View file

@ -2,14 +2,22 @@
* Feedback Service Instance for Calendar Web App
*/
import { browser } from '$app/environment';
import { createFeedbackService } from '@manacore/shared-feedback-service';
import { authStore } from '$lib/stores/auth.svelte';
import { PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public';
const MANA_AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
// Get auth URL dynamically at runtime
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
return 'http://localhost:3001';
}
export const feedbackService = createFeedbackService({
apiUrl: MANA_AUTH_URL,
apiUrl: getAuthUrl(),
appId: 'calendar',
getAuthToken: async () => authStore.getAccessToken(),
});

View file

@ -7,8 +7,18 @@ import { browser } from '$app/environment';
import { initializeWebAuth } from '@manacore/shared-auth';
import type { UserData } from '@manacore/shared-auth';
// Initialize Mana Core Auth only on the client side
const MANA_AUTH_URL = 'http://localhost:3001';
// Get auth URL dynamically at runtime - fallback for SSR and client
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
// Client-side: use injected window variable (set by hooks.server.ts)
// Falls back to localhost for local development
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
// Server-side (SSR): use Docker internal URL for container-to-container communication
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
}
// Lazy initialization to avoid SSR issues with localStorage
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
@ -17,7 +27,7 @@ let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null =
function getAuthService() {
if (!browser) return null;
if (!_authService) {
const auth = initializeWebAuth({ baseUrl: MANA_AUTH_URL });
const auth = initializeWebAuth({ baseUrl: getAuthUrl() });
_authService = auth.authService;
_tokenManager = auth.tokenManager;
}

View file

@ -7,13 +7,22 @@
* - localStorage caching for offline support
*/
import { browser } from '$app/environment';
import { createUserSettingsStore } from '@manacore/shared-theme';
import { authStore } from './auth.svelte';
const MANA_AUTH_URL = 'http://localhost:3001';
// Get auth URL dynamically at runtime
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
return 'http://localhost:3001';
}
export const userSettings = createUserSettingsStore({
appId: 'calendar',
authUrl: MANA_AUTH_URL,
authUrl: getAuthUrl(),
getAccessToken: () => authStore.getAccessToken(),
});

View file

@ -32,52 +32,7 @@
</div>
</div>
{:else}
<div class="layout-container">
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Kalender"
homeRoute="/"
onToggleTheme={handleToggleTheme}
{isDark}
{isSidebarMode}
onModeChange={handleModeChange}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
desktopPosition={userSettings.nav.desktopPosition}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={authStore.isAuthenticated}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#3b82f6"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
manaHref="/mana"
profileHref="/profile"
allAppsHref="/apps"
/>
<main
class="main-content bg-background"
class:sidebar-mode={isSidebarMode && !isCollapsed}
class:floating-mode={!isSidebarMode && !isCollapsed}
>
<div
class="content-wrapper"
class:calendar-expanded={settingsStore.sidebarCollapsed && $page.url.pathname === '/'}
>
{@render children()}
</div>
</main>
<div class="min-h-screen bg-background text-foreground">
{@render children()}
</div>
{/if}

View file

@ -1,11 +1,13 @@
import adapter from '@sveltejs/adapter-auto';
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
adapter: adapter({
out: 'build',
}),
},
};

View file

@ -1,6 +1,14 @@
# Build stage
FROM node:20-alpine AS builder
# Build arguments for SvelteKit static env vars
ARG PUBLIC_BACKEND_URL=http://chat-backend:3002
ARG PUBLIC_MANA_CORE_AUTH_URL=http://mana-core-auth:3001
# Set as environment variables for build
ENV PUBLIC_BACKEND_URL=$PUBLIC_BACKEND_URL
ENV PUBLIC_MANA_CORE_AUTH_URL=$PUBLIC_MANA_CORE_AUTH_URL
# Install pnpm
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
@ -47,15 +55,19 @@ RUN pnpm build
# Production stage
FROM node:20-alpine AS production
WORKDIR /app
# Keep same directory structure as builder so pnpm symlinks resolve correctly
WORKDIR /app/apps/chat/apps/web
# Copy the pnpm store that symlinks point to (at /app/node_modules/.pnpm)
COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm
# Copy the app's node_modules (contains symlinks to the pnpm store)
COPY --from=builder /app/apps/chat/apps/web/node_modules ./node_modules
# Copy built application
COPY --from=builder /app/apps/chat/apps/web/build ./build
COPY --from=builder /app/apps/chat/apps/web/package.json ./
# Install only production dependencies for the built app
RUN npm install --omit=dev 2>/dev/null || true
# Expose port
EXPOSE 3000

View file

@ -0,0 +1,68 @@
# Build stage
FROM node:20-alpine AS builder
# Install pnpm
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
WORKDIR /app
# Copy root workspace files
COPY pnpm-workspace.yaml ./
COPY package.json ./
COPY pnpm-lock.yaml ./
# Copy shared packages
COPY packages/shared-errors ./packages/shared-errors
COPY packages/shared-nestjs-auth ./packages/shared-nestjs-auth
# Copy clock packages and backend
COPY apps/clock/packages ./apps/clock/packages
COPY apps/clock/apps/backend ./apps/clock/apps/backend
# Install dependencies
RUN pnpm install --frozen-lockfile
# Build shared packages first
WORKDIR /app/packages/shared-errors
RUN pnpm build
WORKDIR /app/packages/shared-nestjs-auth
RUN pnpm build
# Build the backend
WORKDIR /app/apps/clock/apps/backend
RUN pnpm build
# Production stage
FROM node:20-alpine AS production
# Install pnpm and postgresql-client for health checks
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate \
&& apk add --no-cache postgresql-client
WORKDIR /app
# Copy everything from builder (including node_modules)
COPY --from=builder /app/pnpm-workspace.yaml ./
COPY --from=builder /app/package.json ./
COPY --from=builder /app/pnpm-lock.yaml ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/packages ./packages
COPY --from=builder /app/apps/clock ./apps/clock
# Copy entrypoint script
COPY apps/clock/apps/backend/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
WORKDIR /app/apps/clock/apps/backend
# Expose port
EXPOSE 3017
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3017/api/v1/health || exit 1
# Run entrypoint script
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "dist/main.js"]

View file

@ -0,0 +1,23 @@
#!/bin/sh
set -e
echo "=== Clock Backend Entrypoint ==="
# Wait for PostgreSQL to be ready
echo "Waiting for PostgreSQL..."
until pg_isready -h ${DB_HOST:-postgres} -p ${DB_PORT:-5432} -U ${DB_USER:-postgres} 2>/dev/null; do
echo "PostgreSQL is unavailable - sleeping"
sleep 2
done
echo "PostgreSQL is up!"
cd /app/apps/clock/apps/backend
# Run schema push
echo "Pushing database schema..."
npx drizzle-kit push --force
echo "Schema push completed!"
# Execute the main command
echo "Starting application..."
exec "$@"

View file

@ -0,0 +1,84 @@
# Build stage
FROM node:20-alpine AS builder
# Build arguments for SvelteKit static env vars
ARG PUBLIC_BACKEND_URL=http://clock-backend:3017
ARG PUBLIC_MANA_CORE_AUTH_URL=http://mana-core-auth:3001
# Set as environment variables for build
ENV PUBLIC_BACKEND_URL=$PUBLIC_BACKEND_URL
ENV PUBLIC_MANA_CORE_AUTH_URL=$PUBLIC_MANA_CORE_AUTH_URL
# Install pnpm
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
WORKDIR /app
# Copy root workspace files
COPY pnpm-workspace.yaml ./
COPY package.json ./
COPY pnpm-lock.yaml ./
# Copy shared packages needed by clock web
COPY packages/shared-auth ./packages/shared-auth
COPY packages/shared-auth-ui ./packages/shared-auth-ui
COPY packages/shared-branding ./packages/shared-branding
COPY packages/shared-feedback-service ./packages/shared-feedback-service
COPY packages/shared-feedback-types ./packages/shared-feedback-types
COPY packages/shared-feedback-ui ./packages/shared-feedback-ui
COPY packages/shared-i18n ./packages/shared-i18n
COPY packages/shared-icons ./packages/shared-icons
COPY packages/shared-tailwind ./packages/shared-tailwind
COPY packages/shared-theme ./packages/shared-theme
COPY packages/shared-theme-ui ./packages/shared-theme-ui
COPY packages/shared-subscription-types ./packages/shared-subscription-types
COPY packages/shared-subscription-ui ./packages/shared-subscription-ui
COPY packages/shared-profile-ui ./packages/shared-profile-ui
COPY packages/shared-ui ./packages/shared-ui
COPY packages/shared-utils ./packages/shared-utils
# Copy clock packages and web
COPY apps/clock/packages ./apps/clock/packages
COPY apps/clock/apps/web ./apps/clock/apps/web
# Install dependencies
RUN pnpm install --frozen-lockfile
# Build shared packages that need building
WORKDIR /app/packages/shared-auth
RUN pnpm build || true
# Build the web app
WORKDIR /app/apps/clock/apps/web
RUN pnpm build
# Production stage
FROM node:20-alpine AS production
# Keep same directory structure as builder so pnpm symlinks resolve correctly
WORKDIR /app/apps/clock/apps/web
# Copy the pnpm store that symlinks point to (at /app/node_modules/.pnpm)
COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm
# Copy the app's node_modules (contains symlinks to the pnpm store)
COPY --from=builder /app/apps/clock/apps/web/node_modules ./node_modules
# Copy built application
COPY --from=builder /app/apps/clock/apps/web/build ./build
COPY --from=builder /app/apps/clock/apps/web/package.json ./
# Expose port
EXPOSE 5187
# Set environment variables
ENV NODE_ENV=production
ENV PORT=5187
ENV HOST=0.0.0.0
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:5187/health || exit 1
# Run the app
CMD ["node", "build"]

View file

@ -13,7 +13,7 @@
"type-check": "echo 'Skipping type-check for now'"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/adapter-node": "^5.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.1.7",

View file

@ -0,0 +1,27 @@
/**
* Server Hooks for SvelteKit
* - Injects runtime environment variables for client-side use
* - Auth is handled client-side via Mana Core Auth
*/
import type { Handle } from '@sveltejs/kit';
// Get client-side URLs from environment (Docker runtime)
const PUBLIC_MANA_CORE_AUTH_URL_CLIENT =
process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_CORE_AUTH_URL || '';
const PUBLIC_BACKEND_URL_CLIENT =
process.env.PUBLIC_BACKEND_URL_CLIENT || process.env.PUBLIC_BACKEND_URL || '';
export const handle: Handle = async ({ event, resolve }) => {
return resolve(event, {
transformPageChunk: ({ html }) => {
// Inject runtime environment variables into the HTML
// These will be available on window.__PUBLIC_*__ for client-side code
const envScript = `<script>
window.__PUBLIC_MANA_CORE_AUTH_URL__ = "${PUBLIC_MANA_CORE_AUTH_URL_CLIENT}";
window.__PUBLIC_BACKEND_URL__ = "${PUBLIC_BACKEND_URL_CLIENT}";
</script>`;
return html.replace('<head>', `<head>${envScript}`);
},
});
};

View file

@ -6,8 +6,18 @@
import { browser } from '$app/environment';
import { initializeWebAuth, type UserData } from '@manacore/shared-auth';
// Initialize Mana Core Auth only on the client side
const MANA_AUTH_URL = 'http://localhost:3001';
// Get auth URL dynamically at runtime - fallback for SSR and client
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
// Client-side: use injected window variable (set by hooks.server.ts)
// Falls back to localhost for local development
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
// Server-side (SSR): use Docker internal URL for container-to-container communication
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
}
// Lazy initialization to avoid SSR issues with localStorage
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
@ -16,7 +26,7 @@ let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null =
function getAuthService() {
if (!browser) return null;
if (!_authService) {
const auth = initializeWebAuth({ baseUrl: MANA_AUTH_URL });
const auth = initializeWebAuth({ baseUrl: getAuthUrl() });
_authService = auth.authService;
_tokenManager = auth.tokenManager;
}

View file

@ -7,13 +7,22 @@
* - localStorage caching for offline support
*/
import { browser } from '$app/environment';
import { createUserSettingsStore } from '@manacore/shared-theme';
import { authStore } from './auth.svelte';
const MANA_AUTH_URL = 'http://localhost:3001';
// Get auth URL dynamically at runtime
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
return 'http://localhost:3001';
}
export const userSettings = createUserSettingsStore({
appId: 'clock',
authUrl: MANA_AUTH_URL,
authUrl: getAuthUrl(),
getAccessToken: () => authStore.getAccessToken(),
});

View file

@ -1,12 +1,23 @@
<script lang="ts">
import { browser } from '$app/environment';
import { FeedbackPage } from '@manacore/shared-feedback-ui';
import { createFeedbackService } from '@manacore/shared-feedback-service';
import { authStore } from '$lib/stores/auth.svelte';
import '$lib/i18n';
// Get auth URL dynamically at runtime
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
return 'http://localhost:3001';
}
const feedbackService = createFeedbackService({
appName: 'clock',
apiUrl: 'http://localhost:3001', // Mana Core API
apiUrl: getAuthUrl(),
});
async function handleSubmit(data: { type: string; message: string; email?: string }) {

View file

@ -1,34 +1,30 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { LoginPage } from '@manacore/shared-auth-ui';
import { ClockLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
import '$lib/i18n';
let error = $state('');
let loading = $state(false);
// Get redirect URL from query params
const redirectTo = $derived($page.url.searchParams.get('redirectTo') || '/');
async function handleLogin(email: string, password: string) {
loading = true;
error = '';
const result = await authStore.signIn(email, password);
if (result.success) {
goto('/');
} else {
error = result.error || 'Login fehlgeschlagen';
}
loading = false;
async function handleSignIn(email: string, password: string) {
return authStore.signIn(email, password);
}
</script>
<LoginPage
appName="Clock"
appLogo=""
{loading}
{error}
onSubmit={handleLogin}
registerHref="/register"
forgotPasswordHref="/forgot-password"
logo={ClockLogo}
primaryColor="#f59e0b"
onSignIn={handleSignIn}
{goto}
enableGoogle={false}
enableApple={false}
successRedirect={redirectTo}
registerPath="/register"
forgotPasswordPath="/forgot-password"
lightBackground="#fef3c7"
darkBackground="#1c1917"
/>

View file

@ -1,11 +1,13 @@
import adapter from '@sveltejs/adapter-auto';
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
adapter: adapter({
out: 'build',
}),
},
};

View file

@ -50,9 +50,9 @@ export const BatchDocumentCreator: React.FC<BatchDocumentCreatorProps> = ({
const [subjectList, setSubjectList] = useState<string[]>([]);
const [documents, setDocuments] = useState<Document[]>([]);
const [selectedDocuments, setSelectedDocuments] = useState<string[]>([]);
const [documentFilter, setDocumentFilter] = useState<
'all' | 'text' | 'context' | 'prompt'
>('context');
const [documentFilter, setDocumentFilter] = useState<'all' | 'text' | 'context' | 'prompt'>(
'context'
);
const [promptDocuments, setPromptDocuments] = useState<Document[]>([]);
const { mode, colors } = useTheme();
const isDark = mode === 'dark';

View file

@ -216,9 +216,7 @@ export const Breadcrumbs: React.FC<BreadcrumbsProps> = ({
? 'font-medium text-gray-800 dark:text-gray-200'
: 'text-gray-500 dark:text-gray-400'
)}
style={[
pressed && !isLast && styles.textHovered,
]}
style={[pressed && !isLast && styles.textHovered]}
>
{item.label}
</Text>

View file

@ -186,7 +186,7 @@ export const FilterPill: React.FC<FilterPillProps> = ({
: isDark
? '#1f2937'
: '#d1d5db',
opacity: disabled ? 0.6 : (pressed ? 0.8 : 1),
opacity: disabled ? 0.6 : pressed ? 0.8 : 1,
},
]}
onPress={disabled ? undefined : actionButton.onPress}

View file

@ -0,0 +1,85 @@
# Build stage
FROM node:20-alpine AS builder
# Build arguments for SvelteKit static env vars
ARG PUBLIC_MANA_CORE_AUTH_URL=http://mana-core-auth:3001
ARG MIDDLEWARE_URL=http://mana-core-middleware:3000
# Set as environment variables for build
ENV PUBLIC_MANA_CORE_AUTH_URL=$PUBLIC_MANA_CORE_AUTH_URL
ENV MIDDLEWARE_URL=$MIDDLEWARE_URL
# Install pnpm
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
WORKDIR /app
# Copy root workspace files
COPY pnpm-workspace.yaml ./
COPY package.json ./
COPY pnpm-lock.yaml ./
# Copy shared packages needed by manacore web
COPY packages/shared-auth ./packages/shared-auth
COPY packages/shared-auth-ui ./packages/shared-auth-ui
COPY packages/shared-branding ./packages/shared-branding
COPY packages/shared-config ./packages/shared-config
COPY packages/shared-feedback-service ./packages/shared-feedback-service
COPY packages/shared-feedback-types ./packages/shared-feedback-types
COPY packages/shared-feedback-ui ./packages/shared-feedback-ui
COPY packages/shared-i18n ./packages/shared-i18n
COPY packages/shared-icons ./packages/shared-icons
COPY packages/shared-tailwind ./packages/shared-tailwind
COPY packages/shared-theme ./packages/shared-theme
COPY packages/shared-theme-ui ./packages/shared-theme-ui
COPY packages/shared-subscription-types ./packages/shared-subscription-types
COPY packages/shared-subscription-ui ./packages/shared-subscription-ui
COPY packages/shared-profile-ui ./packages/shared-profile-ui
COPY packages/shared-types ./packages/shared-types
COPY packages/shared-ui ./packages/shared-ui
COPY packages/shared-utils ./packages/shared-utils
# Copy manacore web
COPY apps/manacore/apps/web ./apps/manacore/apps/web
# Install dependencies
RUN pnpm install --frozen-lockfile
# Build shared packages that need building
WORKDIR /app/packages/shared-auth
RUN pnpm build || true
# Build the web app
WORKDIR /app/apps/manacore/apps/web
RUN pnpm build
# Production stage
FROM node:20-alpine AS production
# Keep same directory structure as builder so pnpm symlinks resolve correctly
WORKDIR /app/apps/manacore/apps/web
# Copy the pnpm store that symlinks point to (at /app/node_modules/.pnpm)
COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm
# Copy the app's node_modules (contains symlinks to the pnpm store)
COPY --from=builder /app/apps/manacore/apps/web/node_modules ./node_modules
# Copy built application
COPY --from=builder /app/apps/manacore/apps/web/build ./build
COPY --from=builder /app/apps/manacore/apps/web/package.json ./
# Expose port
EXPOSE 5173
# Set environment variables
ENV NODE_ENV=production
ENV PORT=5173
ENV HOST=0.0.0.0
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:5173/health || exit 1
# Run the app
CMD ["node", "build"]

View file

@ -16,8 +16,7 @@
},
"devDependencies": {
"@playwright/test": "^1.51.0",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-netlify": "^5.2.4",
"@sveltejs/adapter-node": "^5.0.0",
"@sveltejs/kit": "^2.15.7",
"@sveltejs/vite-plugin-svelte": "^5.0.4",
"@tailwindcss/postcss": "^4.1.17",
@ -50,15 +49,12 @@
"@manacore/shared-profile-ui": "workspace:*",
"@manacore/shared-subscription-types": "workspace:*",
"@manacore/shared-subscription-ui": "workspace:*",
"@manacore/shared-supabase": "workspace:*",
"@manacore/shared-tailwind": "workspace:*",
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-theme-ui": "workspace:*",
"@manacore/shared-types": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"@manacore/shared-utils": "workspace:*",
"@supabase/ssr": "^0.5.2",
"@supabase/supabase-js": "^2.81.1",
"svelte-dnd-action": "^0.9.68",
"svelte-i18n": "^4.0.0"
},

View file

@ -1,18 +1,15 @@
import type { Session, SupabaseClient, User } from '@supabase/supabase-js';
/**
* App type declarations for ManaCore web app
*
* Authentication is handled entirely by Mana Core Auth (@manacore/shared-auth).
* No Supabase is needed - all data comes from mana-core-auth APIs.
*/
declare global {
namespace App {
interface Locals {
supabase: SupabaseClient;
safeGetSession: () => Promise<{ session: Session | null; user: User | null }>;
session: Session | null;
user: User | null;
}
interface PageData {
// Auth is handled by Mana Core Auth (@manacore/shared-auth), not Supabase
// Supabase is used for database operations only
supabase?: SupabaseClient;
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface Locals {}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface PageData {}
// interface Error {}
// interface Platform {}
}

View file

@ -1,29 +1,38 @@
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
import { createServerClient } from '@supabase/ssr';
import type { Handle } from '@sveltejs/kit';
/**
* Server hooks for ManaCore web app
*
* Note: Authentication is handled client-side via Mana Core Auth.
* Supabase is only used for database operations (not auth).
* Injects runtime environment variables into the HTML for client-side access.
* This is necessary because SvelteKit's $env/static/public bakes values at build time,
* but Docker containers need runtime configuration.
*/
export const handle: Handle = async ({ event, resolve }) => {
// Create Supabase client for database operations only
event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
cookies: {
getAll: () => event.cookies.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) => {
event.cookies.set(name, value, { ...options, path: '/' });
});
},
},
}) as any;
// Auth URL
const PUBLIC_MANA_CORE_AUTH_URL_CLIENT =
process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_CORE_AUTH_URL || '';
// Backend URLs for dashboard widgets
const PUBLIC_TODO_API_URL_CLIENT =
process.env.PUBLIC_TODO_API_URL_CLIENT || process.env.PUBLIC_TODO_API_URL || '';
const PUBLIC_CALENDAR_API_URL_CLIENT =
process.env.PUBLIC_CALENDAR_API_URL_CLIENT || process.env.PUBLIC_CALENDAR_API_URL || '';
const PUBLIC_CLOCK_API_URL_CLIENT =
process.env.PUBLIC_CLOCK_API_URL_CLIENT || process.env.PUBLIC_CLOCK_API_URL || '';
const PUBLIC_CONTACTS_API_URL_CLIENT =
process.env.PUBLIC_CONTACTS_API_URL_CLIENT || process.env.PUBLIC_CONTACTS_API_URL || '';
export const handle: Handle = async ({ event, resolve }) => {
return resolve(event, {
filterSerializedResponseHeaders(name) {
return name === 'content-range' || name === 'x-supabase-api-version';
transformPageChunk: ({ html }) => {
const envScript = `<script>
window.__PUBLIC_MANA_CORE_AUTH_URL__ = "${PUBLIC_MANA_CORE_AUTH_URL_CLIENT}";
window.__PUBLIC_TODO_API_URL__ = "${PUBLIC_TODO_API_URL_CLIENT}";
window.__PUBLIC_CALENDAR_API_URL__ = "${PUBLIC_CALENDAR_API_URL_CLIENT}";
window.__PUBLIC_CLOCK_API_URL__ = "${PUBLIC_CLOCK_API_URL_CLIENT}";
window.__PUBLIC_CONTACTS_API_URL__ = "${PUBLIC_CONTACTS_API_URL_CLIENT}";
</script>`;
return html.replace('<head>', `<head>${envScript}`);
},
});
};

View file

@ -4,12 +4,32 @@
* Fetches events from the Calendar backend for dashboard widgets.
*/
import { browser } from '$app/environment';
import { createApiClient, type ApiResult } from '../base-client';
// Backend URL - falls back to localhost for development
const CALENDAR_API_URL = import.meta.env.PUBLIC_CALENDAR_API_URL || 'http://localhost:3014/api/v1';
// Get Calendar API URL dynamically at runtime
function getCalendarApiUrl(): string {
if (browser && typeof window !== 'undefined') {
// Client-side: use injected window variable (set by hooks.server.ts)
const injectedUrl = (window as unknown as { __PUBLIC_CALENDAR_API_URL__?: string })
.__PUBLIC_CALENDAR_API_URL__;
if (injectedUrl) {
return `${injectedUrl}/api/v1`;
}
}
// Fallback for local development
return 'http://localhost:3016/api/v1';
}
const client = createApiClient(CALENDAR_API_URL);
// Lazy-initialized client to ensure we get the correct URL at runtime
let _client: ReturnType<typeof createApiClient> | null = null;
function getClient() {
if (!_client) {
_client = createApiClient(getCalendarApiUrl());
}
return _client;
}
/**
* Calendar entity from Calendar backend
@ -59,7 +79,7 @@ export const calendarService = {
const startDate = new Date().toISOString().split('T')[0];
const endDate = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const result = await client.get<{ events: CalendarEvent[] }>(
const result = await getClient().get<{ events: CalendarEvent[] }>(
`/events?startDate=${startDate}&endDate=${endDate}`
);
@ -75,7 +95,7 @@ export const calendarService = {
*/
async getTodayEvents(): Promise<ApiResult<CalendarEvent[]>> {
const today = new Date().toISOString().split('T')[0];
const result = await client.get<{ events: CalendarEvent[] }>(
const result = await getClient().get<{ events: CalendarEvent[] }>(
`/events?startDate=${today}&endDate=${today}`
);
@ -90,7 +110,7 @@ export const calendarService = {
* Get all calendars
*/
async getCalendars(): Promise<ApiResult<Calendar[]>> {
const result = await client.get<{ calendars: Calendar[] }>('/calendars');
const result = await getClient().get<{ calendars: Calendar[] }>('/calendars');
if (result.error || !result.data) {
return { data: null, error: result.error };
@ -109,7 +129,7 @@ export const calendarService = {
const startDate = new Date().toISOString().split('T')[0];
const endDate = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const result = await client.get<{ events: CalendarEvent[] }>(
const result = await getClient().get<{ events: CalendarEvent[] }>(
`/events?calendarIds=${calendarId}&startDate=${startDate}&endDate=${endDate}`
);

View file

@ -4,12 +4,32 @@
* Fetches contacts from the Contacts backend for dashboard widgets.
*/
import { browser } from '$app/environment';
import { createApiClient, type ApiResult } from '../base-client';
// Backend URL - falls back to localhost for development
const CONTACTS_API_URL = import.meta.env.PUBLIC_CONTACTS_API_URL || 'http://localhost:3015/api/v1';
// Get Contacts API URL dynamically at runtime
function getContactsApiUrl(): string {
if (browser && typeof window !== 'undefined') {
// Client-side: use injected window variable (set by hooks.server.ts)
const injectedUrl = (window as unknown as { __PUBLIC_CONTACTS_API_URL__?: string })
.__PUBLIC_CONTACTS_API_URL__;
if (injectedUrl) {
return `${injectedUrl}/api/v1`;
}
}
// Fallback for local development
return 'http://localhost:3015/api/v1';
}
const client = createApiClient(CONTACTS_API_URL);
// Lazy-initialized client to ensure we get the correct URL at runtime
let _client: ReturnType<typeof createApiClient> | null = null;
function getClient() {
if (!_client) {
_client = createApiClient(getContactsApiUrl());
}
return _client;
}
/**
* Contact entity from Contacts backend
@ -55,7 +75,7 @@ export const contactsService = {
* Get favorite contacts
*/
async getFavoriteContacts(limit: number = 5): Promise<ApiResult<Contact[]>> {
const result = await client.get<Contact[]>(`/contacts?isFavorite=true&limit=${limit}`);
const result = await getClient().get<Contact[]>(`/contacts?isFavorite=true&limit=${limit}`);
return result;
},
@ -63,7 +83,7 @@ export const contactsService = {
* Get recent contacts (by updatedAt)
*/
async getRecentContacts(limit: number = 5): Promise<ApiResult<Contact[]>> {
const result = await client.get<Contact[]>(`/contacts?limit=${limit}`);
const result = await getClient().get<Contact[]>(`/contacts?limit=${limit}`);
if (result.error || !result.data) {
return result;
@ -82,7 +102,7 @@ export const contactsService = {
* Get contacts with upcoming birthdays
*/
async getUpcomingBirthdays(days: number = 30): Promise<ApiResult<Contact[]>> {
const result = await client.get<Contact[]>('/contacts');
const result = await getClient().get<Contact[]>('/contacts');
if (result.error || !result.data) {
return result;
@ -113,7 +133,7 @@ export const contactsService = {
* Get contact count
*/
async getContactCount(): Promise<ApiResult<{ total: number; favorites: number }>> {
const result = await client.get<Contact[]>('/contacts');
const result = await getClient().get<Contact[]>('/contacts');
if (result.error || !result.data) {
return { data: null, error: result.error };

View file

@ -4,12 +4,32 @@
* Fetches tasks from the Todo backend for dashboard widgets.
*/
import { browser } from '$app/environment';
import { createApiClient, type ApiResult } from '../base-client';
// Backend URL - falls back to localhost for development
const TODO_API_URL = import.meta.env.PUBLIC_TODO_API_URL || 'http://localhost:3017/api/v1';
// Get Todo API URL dynamically at runtime
function getTodoApiUrl(): string {
if (browser && typeof window !== 'undefined') {
// Client-side: use injected window variable (set by hooks.server.ts)
const injectedUrl = (window as unknown as { __PUBLIC_TODO_API_URL__?: string })
.__PUBLIC_TODO_API_URL__;
if (injectedUrl) {
return `${injectedUrl}/api/v1`;
}
}
// Fallback for local development
return 'http://localhost:3018/api/v1';
}
const client = createApiClient(TODO_API_URL);
// Lazy-initialized client to ensure we get the correct URL at runtime
let _client: ReturnType<typeof createApiClient> | null = null;
function getClient() {
if (!_client) {
_client = createApiClient(getTodoApiUrl());
}
return _client;
}
/**
* Task entity from Todo backend
@ -49,7 +69,7 @@ export const todoService = {
* Get today's tasks
*/
async getTodayTasks(): Promise<ApiResult<Task[]>> {
const result = await client.get<{ tasks: Task[] }>('/tasks/today');
const result = await getClient().get<{ tasks: Task[] }>('/tasks/today');
if (result.error || !result.data) {
return { data: null, error: result.error };
@ -62,7 +82,7 @@ export const todoService = {
* Get upcoming tasks for the next N days
*/
async getUpcomingTasks(days: number = 7): Promise<ApiResult<Task[]>> {
const result = await client.get<{ tasks: Task[] }>(`/tasks/upcoming?days=${days}`);
const result = await getClient().get<{ tasks: Task[] }>(`/tasks/upcoming?days=${days}`);
if (result.error || !result.data) {
return { data: null, error: result.error };
@ -75,7 +95,7 @@ export const todoService = {
* Get inbox tasks (unassigned to project)
*/
async getInboxTasks(): Promise<ApiResult<Task[]>> {
const result = await client.get<{ tasks: Task[] }>('/tasks/inbox');
const result = await getClient().get<{ tasks: Task[] }>('/tasks/inbox');
if (result.error || !result.data) {
return { data: null, error: result.error };
@ -88,7 +108,7 @@ export const todoService = {
* Get all projects
*/
async getProjects(): Promise<ApiResult<Project[]>> {
const result = await client.get<{ projects: Project[] }>('/projects');
const result = await getClient().get<{ projects: Project[] }>('/projects');
if (result.error || !result.data) {
return { data: null, error: result.error };

View file

@ -1,43 +0,0 @@
import type { RequestEvent } from '@sveltejs/kit';
export async function getUser(event: RequestEvent) {
const {
data: { user },
error,
} = await event.locals.supabase.auth.getUser();
if (error) {
console.error('Error fetching user:', error);
return null;
}
return user;
}
export async function getSession(event: RequestEvent) {
const {
data: { session },
error,
} = await event.locals.supabase.auth.getSession();
if (error) {
console.error('Error fetching session:', error);
return null;
}
return session;
}
export async function requireAuth(event: RequestEvent) {
const session = await getSession(event);
if (!session) {
throw new Error('Unauthorized');
}
return session;
}
export function getSupabaseServerClient(event: RequestEvent) {
return event.locals.supabase;
}

View file

@ -7,9 +7,18 @@ import { browser } from '$app/environment';
import { initializeWebAuth } from '@manacore/shared-auth';
import type { UserData } from '@manacore/shared-auth';
// Initialize Mana Core Auth only on the client side
// TODO: Use PUBLIC_MANA_CORE_AUTH_URL from env when available
const MANA_AUTH_URL = 'http://localhost:3001';
// Get auth URL dynamically at runtime - fallback for SSR and client
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
// Client-side: use injected window variable (set by hooks.server.ts)
// Falls back to localhost for local development
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
// Server-side (SSR): use Docker internal URL for container-to-container communication
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
}
// Lazy initialization to avoid SSR issues with localStorage
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
@ -18,7 +27,7 @@ let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null =
function getAuthService() {
if (!browser) return null;
if (!_authService) {
const auth = initializeWebAuth({ baseUrl: MANA_AUTH_URL });
const auth = initializeWebAuth({ baseUrl: getAuthUrl() });
_authService = auth.authService;
_tokenManager = auth.tokenManager;
}
@ -183,6 +192,29 @@ export const authStore = {
}
},
/**
* Reset password with token
*/
async resetPassword(token: string, newPassword: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const result = await authService.resetPassword(token, newPassword);
if (!result.success) {
return { success: false, error: result.error || 'Password reset failed' };
}
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
/**
* Get access token for API calls
*/

View file

@ -7,14 +7,22 @@
* - localStorage caching for offline support
*/
import { browser } from '$app/environment';
import { createUserSettingsStore } from '@manacore/shared-theme';
import { authStore } from './auth.svelte';
// TODO: Use PUBLIC_MANA_CORE_AUTH_URL from env when available
const MANA_AUTH_URL = 'http://localhost:3001';
// Get auth URL dynamically at runtime
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
return 'http://localhost:3001';
}
export const userSettings = createUserSettingsStore({
appId: 'manacore',
authUrl: MANA_AUTH_URL,
authUrl: getAuthUrl(),
getAccessToken: () => authStore.getAccessToken(),
});

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Button, Input, Card, PageHeader } from '@manacore/shared-ui';
import { authStore } from '$lib/stores/authStore.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { creditsService } from '$lib/api/credits';
import type { CreditBalance } from '$lib/api/credits';
import { userSettings } from '$lib/stores/user-settings.svelte';

View file

@ -1,39 +0,0 @@
import { fail } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions: Actions = {
default: async ({ request, locals: { supabase }, url }) => {
const formData = await request.formData();
const email = formData.get('email') as string;
if (!email) {
return fail(400, {
error: 'Email is required',
email,
});
}
// Get the origin for the redirect URL
const origin = url.origin;
const redirectTo = `${origin}/auth/reset-password`;
// Send password reset email
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo,
});
if (error) {
console.error('Password reset error:', error);
return fail(400, {
error: error.message,
email,
});
}
// Return success (we don't reveal if the email exists for security)
return {
success: true,
email,
};
},
};

View file

@ -1,44 +0,0 @@
import { redirect, fail } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions: Actions = {
default: async ({ request, locals: { supabase } }) => {
const formData = await request.formData();
const password = formData.get('password') as string;
const confirmPassword = formData.get('confirmPassword') as string;
// Validate inputs
if (!password || !confirmPassword) {
return fail(400, {
error: 'Both password fields are required',
});
}
if (password.length < 6) {
return fail(400, {
error: 'Password must be at least 6 characters long',
});
}
if (password !== confirmPassword) {
return fail(400, {
error: 'Passwords do not match',
});
}
// Update the user's password
const { error } = await supabase.auth.updateUser({
password,
});
if (error) {
console.error('Password update error:', error);
return fail(400, {
error: error.message,
});
}
// Success - redirect to dashboard
throw redirect(303, '/dashboard');
},
};

View file

@ -1,141 +1,103 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { Button, Input, Card } from '@manacore/shared-ui';
import { authStore } from '$lib/stores/auth.svelte';
let { form } = $props();
let loading = $state(false);
let hasToken = $state(false);
let verifying = $state(true);
let verificationError = $state<string | null>(null);
let token = $state<string | null>(null);
let password = $state('');
let confirmPassword = $state('');
let error = $state<string | null>(null);
let success = $state(false);
onMount(async () => {
// Check if we have tokens in the URL hash (from password recovery email)
const hash = window.location.hash.substring(1); // Remove the '#'
const hashParams = new URLSearchParams(hash);
const accessToken = hashParams.get('access_token');
const refreshToken = hashParams.get('refresh_token');
const type = hashParams.get('type');
// Check if we have a token in the URL query params (from Supabase email link)
const queryToken = $page.url.searchParams.get('token');
const queryType = $page.url.searchParams.get('type');
if (accessToken && refreshToken && type === 'recovery') {
// Have tokens in hash - need to establish session
try {
const response = await fetch('/api/auth/set-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
access_token: accessToken,
refresh_token: refreshToken,
}),
});
const result = await response.json();
if (result.success) {
hasToken = true;
// Clean up URL by removing hash
window.history.replaceState({}, '', window.location.pathname);
} else {
verificationError = result.error || 'Failed to establish session';
}
} catch (error) {
console.error('Session establishment error:', error);
verificationError = 'Failed to establish session';
} finally {
verifying = false;
}
} else if (queryToken && queryType === 'recovery') {
// Have token in query params - need to verify via OTP
try {
const response = await fetch('/api/auth/verify-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: queryToken, type: queryType }),
});
const result = await response.json();
if (result.success) {
hasToken = true;
// Clean up URL by removing query params
window.history.replaceState({}, '', window.location.pathname);
} else {
verificationError = result.error || 'Invalid or expired reset link';
}
} catch (error) {
console.error('Token verification error:', error);
verificationError = 'Failed to verify reset link';
} finally {
verifying = false;
}
} else {
// No token found
verifying = false;
}
onMount(() => {
// Get token from URL query parameter
token = $page.url.searchParams.get('token');
hasToken = !!token;
});
async function handleSubmit(e: Event) {
e.preventDefault();
error = null;
if (!token) {
error = 'Reset token is missing';
return;
}
if (password !== confirmPassword) {
error = 'Passwords do not match';
return;
}
if (password.length < 12) {
error = 'Password must be at least 12 characters';
return;
}
loading = true;
try {
const result = await authStore.resetPassword(token, password);
if (!result.success) {
error = result.error || 'Failed to reset password';
} else {
success = true;
// Redirect to login after 3 seconds
setTimeout(() => {
goto('/login');
}, 3000);
}
} catch (err) {
error = err instanceof Error ? err.message : 'An error occurred';
} finally {
loading = false;
}
}
</script>
<div>
<div class="text-center">
<h2 class="mb-2 text-3xl font-bold text-gray-900 dark:text-white">Reset Password</h2>
<p class="text-gray-600 dark:text-gray-400">
{#if verifying}
Verifying your reset link...
{#if success}
Password reset successfully
{:else if hasToken}
Enter your new password
{:else}
Token missing or expired
Invalid or missing reset token
{/if}
</p>
</div>
{#if verifying}
{#if success}
<Card class="mt-8">
<div class="text-center">
<div
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent"
></div>
<p class="text-gray-600 dark:text-gray-400">Verifying your password reset link...</p>
</div>
</Card>
{:else if verificationError}
<Card class="mt-8">
<div class="text-center">
<div class="mb-4 text-6xl">⚠️</div>
<div class="mb-4 text-6xl"></div>
<p class="mb-4 text-gray-600 dark:text-gray-400">
{verificationError}
Your password has been reset successfully. You will be redirected to the login page
shortly.
</p>
<a
href="/forgot-password"
href="/login"
class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400"
>
Request a new reset link
Go to login
</a>
</div>
</Card>
{:else if hasToken}
<Card class="mt-8">
<form
method="POST"
use:enhance={() => {
loading = true;
return async ({ update }) => {
await update();
loading = false;
};
}}
>
{#if form?.error}
<form onsubmit={handleSubmit}>
{#if error}
<div
class="mb-4 rounded-lg bg-red-50 p-4 text-sm text-red-800 dark:bg-red-900/20 dark:text-red-400"
>
{form.error}
{error}
</div>
{/if}
@ -152,12 +114,13 @@
name="password"
id="password"
autocomplete="new-password"
placeholder="••••••••"
placeholder="Enter new password"
required
minlength={6}
minlength={12}
bind:value={password}
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Must be at least 6 characters
Must be at least 12 characters
</p>
</div>
@ -173,9 +136,10 @@
name="confirmPassword"
id="confirmPassword"
autocomplete="new-password"
placeholder="••••••••"
placeholder="Confirm new password"
required
minlength={6}
minlength={12}
bind:value={confirmPassword}
/>
</div>
@ -195,10 +159,10 @@
This password reset link is invalid or has expired.
</p>
<a
href="/login"
href="/forgot-password"
class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400"
>
Back to login
Request a new reset link
</a>
</div>
</Card>

View file

@ -1,7 +1,8 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ cookies }) => {
return {
cookies: cookies.getAll(),
};
/**
* Server layout load - minimal, auth handled by mana-core-auth client-side
*/
export const load: LayoutServerLoad = async () => {
return {};
};

View file

@ -1,35 +1,14 @@
import { waitLocale } from '$lib/i18n';
import '$lib/i18n'; // This triggers the init() call at module scope
import { createBrowserClient } from '@supabase/ssr';
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
import type { LayoutLoad } from './$types';
export const load: LayoutLoad = async ({ data, depends }) => {
/**
* Layout load function
*
* Auth is handled entirely by Mana Core Auth (@manacore/shared-auth).
* No Supabase is needed - all data comes from mana-core-auth APIs.
*/
export const load: LayoutLoad = async () => {
await waitLocale();
/**
* Declare a dependency so the layout will be invalidated when `invalidate('supabase:auth')` is called.
*/
depends('supabase:auth');
// Create Supabase client for database operations only
// Auth is handled by Mana Core Auth (@manacore/shared-auth)
const supabase = createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
global: {
fetch,
},
cookies: {
getAll() {
return data.cookies;
},
setAll(cookiesToSet) {
// Browser client handles cookies automatically through the browser
// This is a no-op as cookies are managed via document.cookie in the browser
},
},
});
// Note: Auth session is managed by Mana Core Auth via authStore,
// not Supabase auth. Supabase is used for database operations only.
return { supabase };
return {};
};

View file

@ -1,36 +0,0 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ request, locals: { supabase } }) => {
try {
const { access_token, refresh_token } = await request.json();
if (!access_token || !refresh_token) {
return json(
{ success: false, error: 'Access token and refresh token are required' },
{ status: 400 }
);
}
// Set the session using the tokens from the URL hash
const { data, error } = await supabase.auth.setSession({
access_token,
refresh_token,
});
if (error) {
console.error('Set session error:', error);
return json({ success: false, error: error.message }, { status: 400 });
}
if (!data.session) {
return json({ success: false, error: 'Failed to create session' }, { status: 400 });
}
// Session is now set via cookies by the Supabase client
return json({ success: true });
} catch (error) {
console.error('Unexpected error in set session:', error);
return json({ success: false, error: 'An unexpected error occurred' }, { status: 500 });
}
};

View file

@ -1,33 +0,0 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ request, locals: { supabase } }) => {
try {
const { token, type } = await request.json();
if (!token || type !== 'recovery') {
return json({ success: false, error: 'Invalid token or type' }, { status: 400 });
}
// Verify the OTP token and create a session
const { data, error } = await supabase.auth.verifyOtp({
token_hash: token,
type: 'recovery',
});
if (error) {
console.error('Token verification error:', error);
return json({ success: false, error: error.message }, { status: 400 });
}
if (!data.session) {
return json({ success: false, error: 'Failed to create session' }, { status: 400 });
}
// Session is now set via cookies by the Supabase client
return json({ success: true });
} catch (error) {
console.error('Unexpected error in token verification:', error);
return json({ success: false, error: 'An unexpected error occurred' }, { status: 500 });
}
};

View file

@ -1,44 +0,0 @@
import { redirect, fail } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions: Actions = {
default: async ({ request, locals: { supabase } }) => {
const formData = await request.formData();
const password = formData.get('password') as string;
const confirmPassword = formData.get('confirmPassword') as string;
// Validate inputs
if (!password || !confirmPassword) {
return fail(400, {
error: 'Both password fields are required',
});
}
if (password.length < 6) {
return fail(400, {
error: 'Password must be at least 6 characters long',
});
}
if (password !== confirmPassword) {
return fail(400, {
error: 'Passwords do not match',
});
}
// Update the user's password
const { error } = await supabase.auth.updateUser({
password,
});
if (error) {
console.error('Password update error:', error);
return fail(400, {
error: error.message,
});
}
// Success - redirect to dashboard
throw redirect(303, '/dashboard');
},
};

View file

@ -1,214 +1,23 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { Button, Input, Card } from '@manacore/shared-ui';
let { form, data } = $props();
let loading = $state(false);
let hasToken = $state(false);
let verifying = $state(true);
let verificationError = $state<string | null>(null);
onMount(async () => {
// Check if we have tokens in the URL hash (from password recovery email)
const hash = window.location.hash.substring(1); // Remove the '#'
const hashParams = new URLSearchParams(hash);
const accessToken = hashParams.get('access_token');
const refreshToken = hashParams.get('refresh_token');
const type = hashParams.get('type');
// Check if we have a token in the URL query params (from Supabase email link)
const queryToken = $page.url.searchParams.get('token');
const queryType = $page.url.searchParams.get('type');
if (accessToken && refreshToken && type === 'recovery') {
// Have tokens in hash - need to establish session
try {
const response = await fetch('/api/auth/set-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
access_token: accessToken,
refresh_token: refreshToken,
}),
});
const result = await response.json();
if (result.success) {
hasToken = true;
// Clean up URL by removing hash
window.history.replaceState({}, '', '/auth/reset-password');
} else {
verificationError = result.error || 'Failed to establish session';
}
} catch (error) {
console.error('Session establishment error:', error);
verificationError = 'Failed to establish session';
} finally {
verifying = false;
}
} else if (queryToken && queryType === 'recovery') {
// Have token in query params - need to verify via OTP
try {
const response = await fetch('/api/auth/verify-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: queryToken, type: queryType }),
});
const result = await response.json();
if (result.success) {
hasToken = true;
// Clean up URL by removing query params
window.history.replaceState({}, '', '/auth/reset-password');
} else {
verificationError = result.error || 'Invalid or expired reset link';
}
} catch (error) {
console.error('Token verification error:', error);
verificationError = 'Failed to verify reset link';
} finally {
verifying = false;
}
} else {
// No token found
verifying = false;
}
// Redirect to the main reset-password page, preserving the token query parameter
onMount(() => {
const token = $page.url.searchParams.get('token');
const redirectUrl = token ? `/reset-password?token=${token}` : '/reset-password';
goto(redirectUrl, { replaceState: true });
});
</script>
<svelte:head>
<title>Reset Password - ManaCore</title>
</svelte:head>
<div
class="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 dark:bg-gray-900 sm:px-6 lg:px-8"
>
<div class="w-full max-w-md">
<div class="text-center">
<h2 class="mb-2 text-3xl font-bold text-gray-900 dark:text-white">Reset Password</h2>
<p class="text-gray-600 dark:text-gray-400">
{#if verifying}
Verifying your reset link...
{:else if hasToken}
Enter your new password
{:else}
Token missing or expired
{/if}
</p>
</div>
{#if verifying}
<Card class="mt-8">
<div class="text-center">
<div
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent"
></div>
<p class="text-gray-600 dark:text-gray-400">Verifying your password reset link...</p>
</div>
</Card>
{:else if verificationError}
<Card class="mt-8">
<div class="text-center">
<div class="mb-4 text-6xl">⚠️</div>
<p class="mb-4 text-gray-600 dark:text-gray-400">
{verificationError}
</p>
<a
href="/forgot-password"
class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400"
>
Request a new reset link
</a>
</div>
</Card>
{:else if hasToken}
<Card class="mt-8">
<form
method="POST"
use:enhance={() => {
loading = true;
return async ({ update }) => {
await update();
loading = false;
};
}}
>
{#if form?.error}
<div
class="mb-4 rounded-lg bg-red-50 p-4 text-sm text-red-800 dark:bg-red-900/20 dark:text-red-400"
>
{form.error}
</div>
{/if}
<div class="space-y-4">
<div>
<label
for="password"
class="mb-2 block text-sm font-medium text-gray-900 dark:text-gray-100"
>
New Password
</label>
<Input
type="password"
name="password"
id="password"
autocomplete="new-password"
placeholder="••••••••"
required
minlength={6}
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Must be at least 6 characters
</p>
</div>
<div>
<label
for="confirmPassword"
class="mb-2 block text-sm font-medium text-gray-900 dark:text-gray-100"
>
Confirm Password
</label>
<Input
type="password"
name="confirmPassword"
id="confirmPassword"
autocomplete="new-password"
placeholder="••••••••"
required
minlength={6}
/>
</div>
<div>
<Button type="submit" {loading} class="w-full">
{loading ? 'Updating password...' : 'Update password'}
</Button>
</div>
</div>
</form>
</Card>
{:else}
<Card class="mt-8">
<div class="text-center">
<div class="mb-4 text-6xl">⚠️</div>
<p class="mb-4 text-gray-600 dark:text-gray-400">
This password reset link is invalid or has expired.
</p>
<a
href="/login"
class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400"
>
Back to login
</a>
</div>
</Card>
{/if}
<div class="text-center">
<div
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent"
></div>
<p class="text-gray-600 dark:text-gray-400">Redirecting...</p>
</div>
</div>

View file

@ -1,4 +1,4 @@
import adapter from '@sveltejs/adapter-netlify';
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
@ -6,7 +6,9 @@ const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
adapter: adapter({
out: 'build',
}),
alias: {
$lib: 'src/lib',
$components: 'src/lib/components',

View file

@ -0,0 +1,67 @@
# Build stage
FROM node:20-alpine AS builder
# Install pnpm
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
WORKDIR /app
# Copy root workspace files
COPY pnpm-workspace.yaml ./
COPY package.json ./
COPY pnpm-lock.yaml ./
# Copy shared packages
COPY packages/shared-errors ./packages/shared-errors
COPY packages/shared-nestjs-auth ./packages/shared-nestjs-auth
# Copy todo backend
COPY apps/todo/apps/backend ./apps/todo/apps/backend
# Install dependencies
RUN pnpm install --frozen-lockfile
# Build shared packages first
WORKDIR /app/packages/shared-errors
RUN pnpm build
WORKDIR /app/packages/shared-nestjs-auth
RUN pnpm build
# Build the backend
WORKDIR /app/apps/todo/apps/backend
RUN pnpm build
# Production stage
FROM node:20-alpine AS production
# Install pnpm and postgresql-client for health checks
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate \
&& apk add --no-cache postgresql-client
WORKDIR /app
# Copy everything from builder (including node_modules)
COPY --from=builder /app/pnpm-workspace.yaml ./
COPY --from=builder /app/package.json ./
COPY --from=builder /app/pnpm-lock.yaml ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/packages ./packages
COPY --from=builder /app/apps/todo/apps/backend ./apps/todo/apps/backend
# Copy entrypoint script
COPY apps/todo/apps/backend/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
WORKDIR /app/apps/todo/apps/backend
# Expose port
EXPOSE 3018
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3018/api/v1/health || exit 1
# Run entrypoint script
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "dist/main.js"]

View file

@ -0,0 +1,23 @@
#!/bin/sh
set -e
echo "=== Todo Backend Entrypoint ==="
# Wait for PostgreSQL to be ready
echo "Waiting for PostgreSQL..."
until pg_isready -h ${DB_HOST:-postgres} -p ${DB_PORT:-5432} -U ${DB_USER:-postgres} 2>/dev/null; do
echo "PostgreSQL is unavailable - sleeping"
sleep 2
done
echo "PostgreSQL is up!"
cd /app/apps/todo/apps/backend
# Run schema push
echo "Pushing database schema..."
npx drizzle-kit push --force
echo "Schema push completed!"
# Execute the main command
echo "Starting application..."
exec "$@"

View file

@ -0,0 +1,84 @@
# Build stage
FROM node:20-alpine AS builder
# Build arguments for SvelteKit static env vars
ARG PUBLIC_BACKEND_URL=http://todo-backend:3018
ARG PUBLIC_MANA_CORE_AUTH_URL=http://mana-core-auth:3001
# Set as environment variables for build
ENV PUBLIC_BACKEND_URL=$PUBLIC_BACKEND_URL
ENV PUBLIC_MANA_CORE_AUTH_URL=$PUBLIC_MANA_CORE_AUTH_URL
# Install pnpm
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
WORKDIR /app
# Copy root workspace files
COPY pnpm-workspace.yaml ./
COPY package.json ./
COPY pnpm-lock.yaml ./
# Copy shared packages needed by todo web
COPY packages/shared-auth ./packages/shared-auth
COPY packages/shared-auth-ui ./packages/shared-auth-ui
COPY packages/shared-branding ./packages/shared-branding
COPY packages/shared-feedback-service ./packages/shared-feedback-service
COPY packages/shared-feedback-types ./packages/shared-feedback-types
COPY packages/shared-feedback-ui ./packages/shared-feedback-ui
COPY packages/shared-i18n ./packages/shared-i18n
COPY packages/shared-icons ./packages/shared-icons
COPY packages/shared-tailwind ./packages/shared-tailwind
COPY packages/shared-theme ./packages/shared-theme
COPY packages/shared-theme-ui ./packages/shared-theme-ui
COPY packages/shared-subscription-types ./packages/shared-subscription-types
COPY packages/shared-subscription-ui ./packages/shared-subscription-ui
COPY packages/shared-profile-ui ./packages/shared-profile-ui
COPY packages/shared-ui ./packages/shared-ui
COPY packages/shared-utils ./packages/shared-utils
# Copy todo packages and web
COPY apps/todo/packages ./apps/todo/packages
COPY apps/todo/apps/web ./apps/todo/apps/web
# Install dependencies
RUN pnpm install --frozen-lockfile
# Build shared packages that need building
WORKDIR /app/packages/shared-auth
RUN pnpm build || true
# Build the web app
WORKDIR /app/apps/todo/apps/web
RUN pnpm build
# Production stage
FROM node:20-alpine AS production
# Keep same directory structure as builder so pnpm symlinks resolve correctly
WORKDIR /app/apps/todo/apps/web
# Copy the pnpm store that symlinks point to (at /app/node_modules/.pnpm)
COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm
# Copy the app's node_modules (contains symlinks to the pnpm store)
COPY --from=builder /app/apps/todo/apps/web/node_modules ./node_modules
# Copy built application
COPY --from=builder /app/apps/todo/apps/web/build ./build
COPY --from=builder /app/apps/todo/apps/web/package.json ./
# Expose port
EXPOSE 5188
# Set environment variables
ENV NODE_ENV=production
ENV PORT=5188
ENV HOST=0.0.0.0
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:5188/health || exit 1
# Run the app
CMD ["node", "build"]

View file

@ -13,7 +13,7 @@
"type-check": "echo 'Skipping type-check for now'"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/adapter-node": "^5.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.1.7",

View file

@ -0,0 +1,27 @@
/**
* Server Hooks for SvelteKit
* - Injects runtime environment variables for client-side use
* - Auth is handled client-side via Mana Core Auth
*/
import type { Handle } from '@sveltejs/kit';
// Get client-side URLs from environment (Docker runtime)
const PUBLIC_MANA_CORE_AUTH_URL_CLIENT =
process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_CORE_AUTH_URL || '';
const PUBLIC_BACKEND_URL_CLIENT =
process.env.PUBLIC_BACKEND_URL_CLIENT || process.env.PUBLIC_BACKEND_URL || '';
export const handle: Handle = async ({ event, resolve }) => {
return resolve(event, {
transformPageChunk: ({ html }) => {
// Inject runtime environment variables into the HTML
// These will be available on window.__PUBLIC_*__ for client-side code
const envScript = `<script>
window.__PUBLIC_MANA_CORE_AUTH_URL__ = "${PUBLIC_MANA_CORE_AUTH_URL_CLIENT}";
window.__PUBLIC_BACKEND_URL__ = "${PUBLIC_BACKEND_URL_CLIENT}";
</script>`;
return html.replace('<head>', `<head>${envScript}`);
},
});
};

View file

@ -12,12 +12,28 @@ interface ApiError {
statusCode: number;
}
/**
* Get the backend URL, preferring runtime-injected value in browser
* This allows Docker to inject PUBLIC_BACKEND_URL_CLIENT at runtime
* instead of using the build-time PUBLIC_BACKEND_URL
*/
function getBackendUrl(): string {
if (browser && typeof window !== 'undefined') {
const runtimeUrl = (window as Window & { __PUBLIC_BACKEND_URL__?: string })
.__PUBLIC_BACKEND_URL__;
if (runtimeUrl) {
return runtimeUrl;
}
}
return PUBLIC_BACKEND_URL || 'http://localhost:3018';
}
class ApiClient {
private baseUrl: string;
private accessToken: string | null = null;
constructor() {
this.baseUrl = PUBLIC_BACKEND_URL || 'http://localhost:3018';
// Use getter to evaluate URL at request time (browser may hydrate after construction)
private get baseUrl(): string {
return getBackendUrl();
}
setAccessToken(token: string | null) {

View file

@ -7,8 +7,18 @@ import { browser } from '$app/environment';
import { initializeWebAuth, type UserData } from '@manacore/shared-auth';
import { apiClient } from '$lib/api/client';
// Initialize Mana Core Auth only on the client side
const MANA_AUTH_URL = 'http://localhost:3001';
// Get auth URL dynamically at runtime - fallback for SSR and client
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
// Client-side: use injected window variable (set by hooks.server.ts)
// Falls back to localhost for local development
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
// Server-side (SSR): use Docker internal URL for container-to-container communication
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
}
// Lazy initialization to avoid SSR issues with localStorage
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
@ -17,7 +27,7 @@ let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null =
function getAuthService() {
if (!browser) return null;
if (!_authService) {
const auth = initializeWebAuth({ baseUrl: MANA_AUTH_URL });
const auth = initializeWebAuth({ baseUrl: getAuthUrl() });
_authService = auth.authService;
_tokenManager = auth.tokenManager;
}

View file

@ -1,10 +1,19 @@
import { browser } from '$app/environment';
import { createUserSettingsStore } from '@manacore/shared-theme';
import { authStore } from './auth.svelte';
const MANA_AUTH_URL = import.meta.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
// Get auth URL dynamically at runtime
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
return 'http://localhost:3001';
}
export const userSettings = createUserSettingsStore({
appId: 'todo',
authUrl: MANA_AUTH_URL,
authUrl: getAuthUrl(),
getAccessToken: () => authStore.getAccessToken(),
});

View file

@ -1,11 +1,13 @@
import adapter from '@sveltejs/adapter-auto';
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
adapter: adapter({
out: 'build',
}),
},
};

View file

@ -74,8 +74,8 @@ services:
JWT_SECRET: ${JWT_SECRET}
JWT_PUBLIC_KEY: ${JWT_PUBLIC_KEY}
JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY}
# CORS - Allow chat-web and other staging origins
CORS_ORIGINS: http://46.224.108.214:3000,http://46.224.108.214:3002,http://localhost:3000
# CORS - Allow all staging web app origins
CORS_ORIGINS: http://46.224.108.214:3000,http://46.224.108.214:5173,http://46.224.108.214:5186,http://46.224.108.214:5187,http://46.224.108.214:5188,http://localhost:3000,http://localhost:5173,http://localhost:5186,http://localhost:5187,http://localhost:5188
ports:
- "3001:3001"
healthcheck:
@ -159,6 +159,250 @@ services:
max-size: "10m"
max-file: "3"
# ============================================
# Manacore App
# ============================================
manacore-web:
image: ${DOCKER_REGISTRY:-ghcr.io/memo-2023}/manacore-web:${MANACORE_WEB_VERSION:-latest}
container_name: manacore-web-staging
restart: unless-stopped
depends_on:
mana-core-auth:
condition: service_healthy
environment:
NODE_ENV: staging
PORT: 5173
# Auth URLs
PUBLIC_MANA_CORE_AUTH_URL: http://mana-core-auth:3001
PUBLIC_MANA_CORE_AUTH_URL_CLIENT: http://46.224.108.214:3001
# Backend URLs for dashboard widgets
PUBLIC_TODO_API_URL: http://todo-backend:3018
PUBLIC_TODO_API_URL_CLIENT: http://46.224.108.214:3018
PUBLIC_CALENDAR_API_URL: http://calendar-backend:3016
PUBLIC_CALENDAR_API_URL_CLIENT: http://46.224.108.214:3016
PUBLIC_CLOCK_API_URL: http://clock-backend:3017
PUBLIC_CLOCK_API_URL_CLIENT: http://46.224.108.214:3017
ports:
- "5173:5173"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5173/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- manacore-network
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ============================================
# Todo App
# ============================================
todo-backend:
image: ${DOCKER_REGISTRY:-ghcr.io/memo-2023}/todo-backend:${TODO_VERSION:-latest}
container_name: todo-backend-staging
restart: unless-stopped
depends_on:
mana-core-auth:
condition: service_healthy
postgres:
condition: service_healthy
environment:
NODE_ENV: staging
PORT: 3018
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@postgres:5432/todo
DB_HOST: postgres
DB_PORT: 5432
DB_USER: ${POSTGRES_USER:-postgres}
MANA_CORE_AUTH_URL: http://mana-core-auth:3001
CORS_ORIGINS: http://46.224.108.214:5188,http://46.224.108.214:5173,http://localhost:5188,http://localhost:5173
ports:
- "3018:3018"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3018/api/v1/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- manacore-network
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
todo-web:
image: ${DOCKER_REGISTRY:-ghcr.io/memo-2023}/todo-web:${TODO_WEB_VERSION:-latest}
container_name: todo-web-staging
restart: unless-stopped
depends_on:
todo-backend:
condition: service_healthy
environment:
NODE_ENV: staging
PORT: 5188
PUBLIC_BACKEND_URL: http://todo-backend:3018
PUBLIC_MANA_CORE_AUTH_URL: http://mana-core-auth:3001
PUBLIC_BACKEND_URL_CLIENT: http://46.224.108.214:3018
PUBLIC_MANA_CORE_AUTH_URL_CLIENT: http://46.224.108.214:3001
ports:
- "5188:5188"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5188/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- manacore-network
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ============================================
# Calendar App
# ============================================
calendar-backend:
image: ${DOCKER_REGISTRY:-ghcr.io/memo-2023}/calendar-backend:${CALENDAR_VERSION:-latest}
container_name: calendar-backend-staging
restart: unless-stopped
depends_on:
mana-core-auth:
condition: service_healthy
postgres:
condition: service_healthy
environment:
NODE_ENV: staging
PORT: 3016
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@postgres:5432/calendar
DB_HOST: postgres
DB_PORT: 5432
DB_USER: ${POSTGRES_USER:-postgres}
MANA_CORE_AUTH_URL: http://mana-core-auth:3001
CORS_ORIGINS: http://46.224.108.214:5186,http://46.224.108.214:5173,http://localhost:5186,http://localhost:5173
ports:
- "3016:3016"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3016/api/v1/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- manacore-network
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
calendar-web:
image: ${DOCKER_REGISTRY:-ghcr.io/memo-2023}/calendar-web:${CALENDAR_WEB_VERSION:-latest}
container_name: calendar-web-staging
restart: unless-stopped
depends_on:
calendar-backend:
condition: service_healthy
environment:
NODE_ENV: staging
PORT: 5186
PUBLIC_BACKEND_URL: http://calendar-backend:3016
PUBLIC_MANA_CORE_AUTH_URL: http://mana-core-auth:3001
PUBLIC_BACKEND_URL_CLIENT: http://46.224.108.214:3016
PUBLIC_MANA_CORE_AUTH_URL_CLIENT: http://46.224.108.214:3001
ports:
- "5186:5186"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5186/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- manacore-network
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ============================================
# Clock App
# ============================================
clock-backend:
image: ${DOCKER_REGISTRY:-ghcr.io/memo-2023}/clock-backend:${CLOCK_VERSION:-latest}
container_name: clock-backend-staging
restart: unless-stopped
depends_on:
mana-core-auth:
condition: service_healthy
postgres:
condition: service_healthy
environment:
NODE_ENV: staging
PORT: 3017
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@postgres:5432/clock
DB_HOST: postgres
DB_PORT: 5432
DB_USER: ${POSTGRES_USER:-postgres}
MANA_CORE_AUTH_URL: http://mana-core-auth:3001
CORS_ORIGINS: http://46.224.108.214:5187,http://46.224.108.214:5173,http://localhost:5187,http://localhost:5173
ports:
- "3017:3017"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3017/api/v1/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- manacore-network
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
clock-web:
image: ${DOCKER_REGISTRY:-ghcr.io/memo-2023}/clock-web:${CLOCK_WEB_VERSION:-latest}
container_name: clock-web-staging
restart: unless-stopped
depends_on:
clock-backend:
condition: service_healthy
environment:
NODE_ENV: staging
PORT: 5187
PUBLIC_BACKEND_URL: http://clock-backend:3017
PUBLIC_MANA_CORE_AUTH_URL: http://mana-core-auth:3001
PUBLIC_BACKEND_URL_CLIENT: http://46.224.108.214:3017
PUBLIC_MANA_CORE_AUTH_URL_CLIENT: http://46.224.108.214:3001
ports:
- "5187:5187"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5187/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- manacore-network
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ============================================
# Networks
# ============================================

View file

@ -1,21 +1,37 @@
-- Create databases for all services
-- This script runs on first container initialization
-- Create chat database
CREATE DATABASE chat;
-- Create voxel_lava database
CREATE DATABASE voxel_lava;
-- Create storage database (cloud drive)
CREATE DATABASE storage;
-- Create todo database
CREATE DATABASE todo;
-- Core databases
CREATE DATABASE IF NOT EXISTS chat;
CREATE DATABASE IF NOT EXISTS zitare;
CREATE DATABASE IF NOT EXISTS contacts;
CREATE DATABASE IF NOT EXISTS calendar;
CREATE DATABASE IF NOT EXISTS clock;
CREATE DATABASE IF NOT EXISTS todo;
CREATE DATABASE IF NOT EXISTS manadeck;
CREATE DATABASE IF NOT EXISTS storage;
CREATE DATABASE IF NOT EXISTS mail;
CREATE DATABASE IF NOT EXISTS moodlit;
CREATE DATABASE IF NOT EXISTS finance;
CREATE DATABASE IF NOT EXISTS inventory;
CREATE DATABASE IF NOT EXISTS techbase;
CREATE DATABASE IF NOT EXISTS voxel_lava;
CREATE DATABASE IF NOT EXISTS figgos;
-- Grant all privileges to the default user
GRANT ALL PRIVILEGES ON DATABASE chat TO manacore;
GRANT ALL PRIVILEGES ON DATABASE voxel_lava TO manacore;
GRANT ALL PRIVILEGES ON DATABASE manacore TO manacore;
GRANT ALL PRIVILEGES ON DATABASE storage TO manacore;
GRANT ALL PRIVILEGES ON DATABASE zitare TO manacore;
GRANT ALL PRIVILEGES ON DATABASE contacts TO manacore;
GRANT ALL PRIVILEGES ON DATABASE calendar TO manacore;
GRANT ALL PRIVILEGES ON DATABASE clock TO manacore;
GRANT ALL PRIVILEGES ON DATABASE todo TO manacore;
GRANT ALL PRIVILEGES ON DATABASE manadeck TO manacore;
GRANT ALL PRIVILEGES ON DATABASE storage TO manacore;
GRANT ALL PRIVILEGES ON DATABASE mail TO manacore;
GRANT ALL PRIVILEGES ON DATABASE moodlit TO manacore;
GRANT ALL PRIVILEGES ON DATABASE finance TO manacore;
GRANT ALL PRIVILEGES ON DATABASE inventory TO manacore;
GRANT ALL PRIVILEGES ON DATABASE techbase TO manacore;
GRANT ALL PRIVILEGES ON DATABASE voxel_lava TO manacore;
GRANT ALL PRIVILEGES ON DATABASE figgos TO manacore;
GRANT ALL PRIVILEGES ON DATABASE manacore TO manacore;

667
docs/DATABASE_MIGRATIONS.md Normal file
View file

@ -0,0 +1,667 @@
# Database Migration Guide
This document describes database migration best practices, procedures, and tooling for the ManaCore monorepo. **This is a core system concept** - all developers should understand these patterns.
## Table of Contents
1. [Overview](#overview)
2. [Drizzle Migration Internals](#drizzle-migration-internals)
3. [Migration Commands](#migration-commands)
4. [Development vs Production](#development-vs-production)
5. [CI/CD Pipeline](#cicd-pipeline)
6. [Advisory Locks](#advisory-locks)
7. [Zero-Downtime Migrations](#zero-downtime-migrations)
8. [Rollback Procedures](#rollback-procedures)
9. [Troubleshooting](#troubleshooting)
---
## Overview
All backends in the ManaCore monorepo use **Drizzle ORM** for database schema management. We use two different approaches depending on the environment:
| Environment | Command | Purpose |
|-------------|---------|---------|
| **Development** | `drizzle-kit push` | Fast iteration, direct schema sync |
| **Production** | `drizzle-kit generate` + `migrate` | Tracked migrations with history |
### Key Principles
1. **Migrations run BEFORE code deployment** - Ensures database is ready for new code
2. **Advisory locks prevent concurrent migrations** - Safe for multi-replica deployments
3. **Expand-contract pattern for breaking changes** - Zero-downtime schema changes
4. **Data persistence** - Migrations never delete user data unless explicitly requested
### Quick Decision Guide
```
┌─────────────────────────────────────────────────────────────────┐
│ Which command should I use? │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Local development? │
│ └── YES → pnpm db:push (fast, no tracking) │
│ │
│ Staging/Production? │
│ └── YES → pnpm db:generate + pnpm db:migrate (tracked) │
│ │
│ Need to inspect data? │
│ └── YES → pnpm db:studio (opens Drizzle Studio) │
│ │
│ Schema changed by someone else? │
│ └── YES → git pull + pnpm db:push (local) │
│ git pull + pnpm db:migrate (staging/prod) │
│ │
└─────────────────────────────────────────────────────────────────┘
```
---
## Drizzle Migration Internals
Understanding how Drizzle manages migrations is essential for debugging issues.
### The Two Modes
#### 1. Push Mode (`drizzle-kit push`)
**How it works:**
1. Drizzle introspects your TypeScript schema files
2. Drizzle introspects the current database schema
3. Drizzle computes the diff between them
4. Drizzle generates and **immediately executes** the SQL to sync them
**Characteristics:**
- No migration files created
- No history tracking
- Direct database modification
- Interactive confirmation (use `--force` to skip)
**When to use:** Local development, experimentation, prototyping
#### 2. Generate + Migrate Mode (`drizzle-kit generate` + `migrate`)
**How it works:**
**Step 1: Generate** (`drizzle-kit generate`)
1. Drizzle introspects your TypeScript schema files
2. Drizzle reads the last snapshot from `migrations/meta/`
3. Drizzle computes the diff
4. Drizzle creates migration files (SQL + snapshot)
**Step 2: Migrate** (`pnpm db:migrate`)
1. Script reads `migrations/meta/_journal.json`
2. Script queries `__drizzle_migrations` table in database
3. Script determines which migrations haven't been applied
4. Script executes pending migrations in order
5. Script records applied migrations in `__drizzle_migrations`
**Characteristics:**
- Creates versioned SQL files
- Full history tracking
- Repeatable deployments
- Can be reviewed before applying
**When to use:** Staging, production, CI/CD pipelines
### Migration File Structure
```
src/db/migrations/
├── 0000_initial_schema/
│ ├── migration.sql # The actual SQL to execute
│ └── snapshot.json # Schema snapshot AFTER this migration
├── 0001_add_user_preferences/
│ ├── migration.sql
│ └── snapshot.json
├── 0002_add_credits_table/
│ ├── migration.sql
│ └── snapshot.json
└── meta/
└── _journal.json # Migration registry (order + metadata)
```
### The Journal File (`_journal.json`)
This file tracks all generated migrations:
```json
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1733066521000,
"tag": "0000_initial_schema",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1733152921000,
"tag": "0001_add_user_preferences",
"breakpoints": true
}
]
}
```
**Key fields:**
- `idx`: Sequential index (order matters!)
- `tag`: Folder name containing the migration
- `when`: Unix timestamp when generated
- `breakpoints`: Whether to use statement breakpoints
### The Database Tracking Table (`__drizzle_migrations`)
Drizzle creates this table automatically to track applied migrations:
```sql
-- Schema: drizzle
-- Table: __drizzle_migrations
CREATE TABLE drizzle.__drizzle_migrations (
id SERIAL PRIMARY KEY,
hash TEXT NOT NULL,
created_at BIGINT NOT NULL
);
```
**Query applied migrations:**
```sql
SELECT * FROM drizzle.__drizzle_migrations ORDER BY created_at;
```
### How Migration Tracking Works
```
┌─────────────────┐ ┌─────────────────┐
│ _journal.json │ │ __drizzle_ │
│ (filesystem) │ │ migrations (db) │
└────────┬────────┘ └────────┬────────┘
│ │
▼ ▼
[0000, 0001, 0002] [hash_0000, hash_0001]
│ │
└───────────┬───────────┘
Pending: [0002]
Execute 0002/migration.sql
Insert into __drizzle_migrations
```
### Snapshot Files
Each migration includes a `snapshot.json` that captures the **complete schema state** after that migration. This allows Drizzle to:
1. Compute diffs for the next migration
2. Detect schema drift
3. Generate accurate SQL
**Important:** Never modify snapshots manually!
---
## Migration Commands
### All Backends
```bash
# Development - push schema directly (fast, no history)
pnpm db:push
# Generate migration files from schema changes
pnpm db:generate
# Run migrations with advisory locks (production-safe)
pnpm db:migrate
# Open Drizzle Studio for database inspection
pnpm db:studio
```
### Root-Level Commands
```bash
# Setup all databases (creates DBs + pushes schemas)
pnpm setup:db
# Setup specific service
pnpm setup:db:auth
pnpm setup:db:chat
```
### Per-Service Commands
```bash
# mana-core-auth
pnpm --filter mana-core-auth db:push
pnpm --filter mana-core-auth db:generate
pnpm --filter mana-core-auth db:migrate
# chat-backend
pnpm --filter @chat/backend db:push
pnpm --filter @chat/backend db:migrate
```
---
## Development vs Production
### Development Workflow
For local development, use `db:push` for fast iteration:
```bash
# 1. Make schema changes in src/db/schema/*.ts
# 2. Push changes to local database
pnpm db:push
# Or use the full dev command which handles this automatically
pnpm dev:chat:full
```
**Why `push` for development?**
- Instant feedback on schema changes
- No migration file clutter during experimentation
- Automatically handled by `dev:*:full` commands
### Production Workflow
For staging/production, use migration files for trackability:
```bash
# 1. Make schema changes in src/db/schema/*.ts
# 2. Generate migration file
pnpm db:generate --name add_user_preferences
# 3. Review generated SQL
cat src/db/migrations/*/migration.sql
# 4. Commit migration files
git add src/db/migrations/
git commit -m "feat: add user preferences table"
# 5. CI/CD runs migrations automatically on deploy
```
**Why migrations for production?**
- Audit trail of all schema changes
- Repeatable deployments
- Rollback capability (with manual down migrations)
---
## CI/CD Pipeline
### Deployment Flow
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Build │───>│ Create DB │───>│ Migrate │───>│ Deploy │
│ Images │ │ (if new) │ │ Database │ │ Code │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
```
### Migration Step Features
1. **Retry logic** - 3 attempts with exponential backoff (10s, 20s, 30s)
2. **Timeout protection** - 5-minute timeout per migration
3. **Advisory locks** - Prevents concurrent migrations
4. **Graceful fallback** - Falls back to `db:push` if `db:migrate` unavailable
### Staging Deployment
Migrations run automatically after database creation:
```yaml
# .github/workflows/cd-staging.yml
- name: Run database migrations
run: |
docker compose exec -T mana-core-auth pnpm run db:migrate
```
### Production Deployment
Migrations run BEFORE deploying new code:
```yaml
# .github/workflows/cd-production.yml
- name: Run database migrations
run: |
docker compose run --rm mana-core-auth pnpm run db:migrate
- name: Deploy with zero-downtime
run: |
docker compose up -d
```
---
## Advisory Locks
Advisory locks prevent multiple instances from running migrations simultaneously.
### How It Works
```typescript
// services/mana-core-auth/src/db/migrate.ts
const MIGRATION_LOCK_ID = 987654321;
// Acquire lock before migration
await db.execute(sql`SELECT pg_try_advisory_lock(${LOCK_ID})`);
// Run migrations...
// Release lock after migration
await db.execute(sql`SELECT pg_advisory_unlock(${LOCK_ID})`);
```
### Lock Behavior
| Scenario | Behavior |
|----------|----------|
| Lock acquired | Migration runs immediately |
| Lock held by another process | Waits up to 5 minutes, then fails |
| Lock stuck | Manual release required (see Troubleshooting) |
### Lock IDs by Service
| Service | Lock ID |
|---------|---------|
| mana-core-auth | `987654321` |
| chat-backend | (to be assigned) |
| todo-backend | (to be assigned) |
### Migration Script Architecture
The production migration script (`src/db/migrate.ts`) is designed for safe, concurrent-safe deployments:
```
┌─────────────────────────────────────────────────────────────────┐
│ migrate.ts Execution Flow │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. Load environment variables (.env) │
│ └── DATABASE_URL, MIGRATION_TIMEOUT │
│ │
│ 2. Create single-connection pool │
│ └── max: 1 (dedicated migration connection) │
│ │
│ 3. Test database connectivity (with retry) │
│ └── SELECT 1 (max 3 attempts, exponential backoff) │
│ │
│ 4. Acquire advisory lock │
│ ├── pg_try_advisory_lock() - non-blocking attempt │
│ └── If busy: poll every 5s until timeout (default: 5 min) │
│ │
│ 5. Check for migration files │
│ └── If meta/_journal.json missing: exit gracefully │
│ │
│ 6. Run Drizzle migrations │
│ └── migrate(db, { migrationsFolder }) │
│ │
│ 7. Cleanup (always runs, even on error) │
│ ├── Release advisory lock │
│ └── Close database connection │
│ │
└─────────────────────────────────────────────────────────────────┘
```
**Key Components:**
| Component | Purpose | Configuration |
|-----------|---------|---------------|
| `withRetry()` | Retry transient errors (network, connection) | 3 attempts, exponential backoff |
| `acquireLock()` | Non-blocking lock attempt | `pg_try_advisory_lock()` |
| `waitForLock()` | Polling wait for lock | 5s intervals, configurable timeout |
| `releaseLock()` | Release lock in finally block | Always runs |
**Error Handling:**
```typescript
// Transient errors (will retry):
- ECONNREFUSED, ETIMEDOUT, ENOTFOUND
- Connection errors
- PostgreSQL 57P03 (cannot connect now)
// Non-transient errors (immediate failure):
- Missing DATABASE_URL
- SQL syntax errors
- Schema conflicts
- Lock timeout
```
**Exit Codes:**
| Code | Meaning |
|------|---------|
| 0 | Success - all migrations applied |
| 1 | Failure - check logs for details |
---
## Zero-Downtime Migrations
For breaking schema changes, use the **expand-contract pattern**:
### Phase 1: Expand
Add new schema elements alongside existing ones:
```sql
-- Migration: 001_add_full_name.sql
ALTER TABLE users ADD COLUMN full_name TEXT;
```
### Phase 2: Migrate
Update application to write to both, backfill data:
```typescript
// Application code - dual write
await db.update(users).set({
name: newName, // Old column
fullName: newName, // New column
});
// Backfill script
UPDATE users SET full_name = name WHERE full_name IS NULL;
```
### Phase 3: Contract
After 1-2 weeks, remove old column:
```sql
-- Migration: 002_drop_name_column.sql
ALTER TABLE users DROP COLUMN name;
```
### Common Patterns
| Change Type | Approach |
|-------------|----------|
| Add column | Direct `ALTER TABLE ADD COLUMN` |
| Drop column | Remove from code first, wait 2 weeks, then drop |
| Rename column | Add new → dual-write → backfill → drop old |
| Change type | Add new column → backfill with cast → swap |
| Add NOT NULL | Add nullable → backfill → add constraint |
### Index Creation
Always use `CONCURRENTLY` to avoid table locks:
```sql
-- Good
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
-- Bad (locks table)
CREATE INDEX idx_users_email ON users(email);
```
---
## Rollback Procedures
### Automatic Rollback (Not Supported)
Drizzle ORM does not support automatic rollbacks. Plan your migrations carefully.
### Manual Rollback
1. **Write down migration scripts** alongside up migrations:
```
src/db/migrations/
├── 001_add_referrals.up.sql
├── 001_add_referrals.down.sql # Manual rollback script
```
2. **Execute rollback manually**:
```bash
# Connect to database
docker compose exec -T postgres psql -U postgres -d manacore_auth
# Run down migration
\i /path/to/001_add_referrals.down.sql
```
### Rollback Checklist
- [ ] Identify affected migration
- [ ] Verify rollback script exists and is tested
- [ ] Create database backup before rollback
- [ ] Execute rollback in staging first
- [ ] Monitor for issues after rollback
- [ ] Update application code if needed
---
## Troubleshooting
### Migration Lock Stuck
If a migration lock is stuck (process crashed without releasing):
```sql
-- Check for stuck locks
SELECT * FROM pg_locks WHERE locktype = 'advisory';
-- Release specific lock (replace LOCK_ID)
SELECT pg_advisory_unlock(987654321);
-- Release all advisory locks for current session
SELECT pg_advisory_unlock_all();
```
### Migration Timeout
If migrations time out:
1. Check for long-running queries: `SELECT * FROM pg_stat_activity;`
2. Increase timeout: `MIGRATION_TIMEOUT=600 pnpm db:migrate`
3. Break large migrations into smaller steps
### Schema Drift
If staging/production schema differs from expected:
```bash
# Generate migration from current schema
pnpm db:generate --name sync_schema
# Review and apply
pnpm db:migrate
```
### Connection Issues
```bash
# Test database connectivity
docker compose exec -T postgres pg_isready -U postgres
# Check environment variables
echo $DATABASE_URL
# Manual connection test
docker compose exec -T postgres psql -U postgres -d manacore_auth -c "SELECT 1"
```
### Migration Fails in CI/CD
1. Check GitHub Actions logs for specific error
2. Verify DATABASE_URL is correctly set in secrets
3. Ensure database exists before migration runs
4. Check if another migration is running (advisory lock)
---
## Best Practices
### DO
- Run migrations before deploying new code
- Test migrations in staging before production
- Use `CONCURRENTLY` for index creation
- Keep migrations small and focused
- Commit migration files to version control
- Wait 1-2 weeks before dropping columns
### DON'T
- Run `db:push` in production
- Delete migration files after they've been applied
- Modify migration files after they've been applied
- Add NOT NULL without default or backfill
- Create indexes without `CONCURRENTLY`
- Drop columns immediately after removing from code
---
## Migration File Structure
```
services/mana-core-auth/
├── src/db/
│ ├── schema/
│ │ ├── index.ts # Export all schemas
│ │ ├── auth.schema.ts # User, session tables
│ │ └── credits.schema.ts # Credit system tables
│ ├── migrations/
│ │ ├── 0001_initial/
│ │ │ ├── snapshot.json
│ │ │ └── migration.sql
│ │ └── meta/
│ │ └── _journal.json # Migration history
│ ├── connection.ts # Database connection
│ └── migrate.ts # Migration script with locks
└── drizzle.config.ts # Drizzle configuration
```
---
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `DATABASE_URL` | PostgreSQL connection string | Required |
| `MIGRATION_TIMEOUT` | Max seconds for migration | `300` |
---
## References
- [Drizzle ORM Migrations](https://orm.drizzle.team/docs/migrations)
- [PostgreSQL Advisory Locks](https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS)
- [Expand-Contract Pattern](https://martinfowler.com/bliki/ParallelChange.html)
- [Zero-Downtime PostgreSQL Migrations](https://postgres.ai/blog/20210923-zero-downtime-postgres-schema-migrations-lock-timeout-and-retries)

275
docs/LOCAL_DEVELOPMENT.md Normal file
View file

@ -0,0 +1,275 @@
# Local Development Guide
This guide explains how to set up and run applications locally with automatic database setup.
## Quick Start
For any project with a backend, use the `dev:*:full` command:
```bash
pnpm dev:chat:full # Start chat with auth + database setup
pnpm dev:zitare:full # Start zitare with auth + database setup
pnpm dev:contacts:full # Start contacts with auth + database setup
# ... etc
```
These commands automatically:
1. Create the database if it doesn't exist
2. Push the latest schema (Drizzle `db:push`)
3. Start the auth service (mana-core-auth)
4. Start the backend and web app with colored output
## Available Full Dev Commands
| Command | Database | Backend Port | Web Port |
|---------|----------|--------------|----------|
| `pnpm dev:chat:full` | chat | 3002 | 5173 |
| `pnpm dev:zitare:full` | zitare | 3007 | 5177 |
| `pnpm dev:contacts:full` | contacts | 3015 | 5184 |
| `pnpm dev:calendar:full` | calendar | 3014 | 5179 |
| `pnpm dev:clock:full` | clock | 3017 | 5187 |
| `pnpm dev:todo:full` | todo | 3018 | 5188 |
| `pnpm dev:picture:full` | picture | 3006 | 5175 |
## Prerequisites
Before running any `dev:*:full` command:
```bash
# 1. Start Docker infrastructure (PostgreSQL, Redis, MinIO)
pnpm docker:up
# 2. Generate environment files (runs automatically on pnpm install)
pnpm setup:env
```
## Database Setup Commands
### Individual Service Setup
```bash
pnpm setup:db:auth # Setup mana-core-auth database + schema
pnpm setup:db:chat # Setup chat database + schema
pnpm setup:db:zitare # Setup zitare database + schema
pnpm setup:db:contacts # Setup contacts database + schema
pnpm setup:db:calendar # Setup calendar database + schema
pnpm setup:db:clock # Setup clock database + schema
pnpm setup:db:todo # Setup todo database + schema
pnpm setup:db:picture # Setup picture database + schema
```
### Setup All Databases
```bash
pnpm setup:db # Creates ALL databases and pushes ALL schemas
```
This is useful when setting up a fresh environment or after pulling new schema changes.
## How It Works
### Docker Init Script
On first `pnpm docker:up`, the PostgreSQL container runs `docker/init-db/01-create-databases.sql` which creates all databases:
- manacore, chat, zitare, contacts, calendar, clock, todo, manadeck
- storage, mail, moodlit, finance, inventory, techbase, voxel_lava, figgos
### Setup Script
The `scripts/setup-databases.sh` script:
1. **Creates database** if it doesn't exist (using `psql`)
2. **Pushes schema** using `drizzle-kit push --force`
The `--force` flag auto-approves schema changes without interactive prompts.
## Troubleshooting
### Database doesn't exist
If you see `database "xxx" does not exist`:
```bash
# Option 1: Run the setup script
pnpm setup:db:chat # or whichever service
# Option 2: Create manually
PGPASSWORD=devpassword psql -h localhost -U manacore -d postgres -c "CREATE DATABASE chat;"
```
### Schema out of date
If you see errors about missing tables/columns:
```bash
# Push the latest schema
pnpm --filter @chat/backend db:push --force
```
### Port already in use
If auth (port 3001) is already running:
```bash
# Check what's using the port
lsof -i :3001
# Kill the process if needed
kill <PID>
```
### Fresh Start (Nuclear Option)
To completely reset all databases:
```bash
# Stop and remove all containers + volumes
pnpm docker:clean
# Start fresh
pnpm docker:up
# Setup all databases
pnpm setup:db
```
## Apps Without Full Commands
Some apps don't have backends or don't use Drizzle:
| App | Reason |
|-----|--------|
| manacore | No backend (uses other services) |
| manadeck | Backend exists but no db:push |
| bauntown, context, maerchenzauber, memoro, news, nutriphi, presi, quote, reader, storage, wisekeep | No backends |
For these, use the regular dev commands:
```bash
pnpm dev:manacore:web
pnpm dev:manadeck:app
```
## Adding a New Application
### Step 1: Create Project Structure
Create the standard project structure under `apps/`:
```
apps/newproject/
├── apps/
│ ├── backend/ # NestJS API (if needed)
│ ├── mobile/ # Expo React Native app
│ ├── web/ # SvelteKit web app
│ └── landing/ # Astro marketing page
├── packages/ # Project-specific shared code
├── package.json # Workspace root
├── pnpm-workspace.yaml
└── CLAUDE.md # Project documentation
```
### Step 2: Configure Backend Database (if applicable)
If your backend uses Drizzle ORM:
1. **Add database to Docker init** (`docker/init-db/01-create-databases.sql`):
```sql
CREATE DATABASE IF NOT EXISTS newproject;
GRANT ALL PRIVILEGES ON DATABASE newproject TO manacore;
```
2. **Add to setup script** (`scripts/setup-databases.sh`):
In the `setup_service()` function, add a new case:
```bash
newproject)
create_db_if_not_exists "newproject"
push_schema "@newproject/backend" "newproject"
;;
```
Also add to the `ALL_DATABASES` array:
```bash
ALL_DATABASES=(
...
"newproject"
)
```
And to the services loop at the bottom:
```bash
for service in auth chat ... newproject; do
```
3. **Add DATABASE_URL to `.env.development`**:
```env
NEWPROJECT_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/newproject
```
4. **Update `scripts/generate-env.mjs`** to generate the backend `.env` file.
### Step 3: Add Package.json Scripts
Add to root `package.json`:
```json
{
"scripts": {
// Project-level dev (all apps)
"newproject:dev": "turbo run dev --filter=newproject...",
// Individual app commands
"dev:newproject:web": "pnpm --filter @newproject/web dev",
"dev:newproject:mobile": "pnpm --filter @newproject/mobile dev",
"dev:newproject:backend": "pnpm --filter @newproject/backend dev",
"dev:newproject:landing": "pnpm --filter @newproject/landing dev",
"dev:newproject:app": "turbo run dev --filter=@newproject/web --filter=@newproject/backend",
// Full dev with auto database setup
"dev:newproject:full": "./scripts/setup-databases.sh newproject && ./scripts/setup-databases.sh auth && concurrently -n auth,backend,web -c blue,green,cyan \"pnpm dev:auth\" \"pnpm dev:newproject:backend\" \"pnpm dev:newproject:web\"",
// Database shortcuts
"newproject:db:push": "pnpm --filter @newproject/backend db:push",
"newproject:db:studio": "pnpm --filter @newproject/backend db:studio",
// Setup shortcut
"setup:db:newproject": "./scripts/setup-databases.sh newproject"
}
}
```
### Step 4: Create Project CLAUDE.md
Create `apps/newproject/CLAUDE.md` with:
- Project overview
- Structure diagram
- Available commands
- API endpoints (if backend)
- Environment variables
- Tech stack details
See existing projects like `apps/chat/CLAUDE.md` for reference.
### Step 5: Test the Setup
```bash
# Create database and push schema
pnpm setup:db:newproject
# Start with full dev command
pnpm dev:newproject:full
```
## Checklist for New Projects
- [ ] Create project structure under `apps/newproject/`
- [ ] Add `pnpm-workspace.yaml` in project root
- [ ] Add database to `docker/init-db/01-create-databases.sql`
- [ ] Add service to `scripts/setup-databases.sh`
- [ ] Add DATABASE_URL to `.env.development`
- [ ] Update `scripts/generate-env.mjs` for env generation
- [ ] Add scripts to root `package.json`
- [ ] Create `CLAUDE.md` with project documentation
- [ ] Test with `pnpm dev:newproject:full`

450
docs/SETUP_TEMPLATES.md Normal file
View file

@ -0,0 +1,450 @@
# Setup Templates & Checklists
Quick-reference templates for recurring setup tasks. Copy and customize for new projects.
## Table of Contents
1. [New SvelteKit Web App](#1-new-sveltekit-web-app)
2. [New NestJS Backend](#2-new-nestjs-backend)
3. [Deploying New Service to Staging](#3-deploying-new-service-to-staging)
4. [Adding Backend to ManaCore Dashboard](#4-adding-backend-to-manacore-dashboard)
5. [Quick Reference Port Assignments](#5-quick-reference-port-assignments)
---
## 1. New SvelteKit Web App
### Checklist
- [ ] Create `src/hooks.server.ts` with runtime env injection
- [ ] Update auth store to use `getAuthUrl()` pattern
- [ ] Update user-settings store to use `getAuthUrl()` pattern
- [ ] Update any API services to use lazy client initialization
- [ ] Add Dockerfile with pnpm symlink preservation
- [ ] Add to `docker-compose.staging.yml` with both internal and client URLs
- [ ] Test locally with `pnpm dev`
- [ ] Deploy and verify `window.__PUBLIC_*__` variables in browser console
### Template: hooks.server.ts
```typescript
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
// Runtime environment variables for client-side injection
const PUBLIC_MANA_CORE_AUTH_URL_CLIENT =
process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_CORE_AUTH_URL || '';
const PUBLIC_BACKEND_URL_CLIENT =
process.env.PUBLIC_BACKEND_URL_CLIENT || process.env.PUBLIC_BACKEND_URL || '';
export const handle: Handle = async ({ event, resolve }) => {
return resolve(event, {
transformPageChunk: ({ html }) => {
const envScript = `<script>
window.__PUBLIC_MANA_CORE_AUTH_URL__ = "${PUBLIC_MANA_CORE_AUTH_URL_CLIENT}";
window.__PUBLIC_BACKEND_URL__ = "${PUBLIC_BACKEND_URL_CLIENT}";
</script>`;
return html.replace('<head>', `<head>${envScript}`);
},
});
};
```
### Template: getAuthUrl() Pattern
```typescript
// src/lib/stores/auth.svelte.ts
import { browser } from '$app/environment';
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
}
// Usage
const auth = initializeWebAuth({ baseUrl: getAuthUrl() });
```
### Template: Lazy API Client Initialization
```typescript
// src/lib/api/services/myservice.ts
import { browser } from '$app/environment';
import { createApiClient } from '../base-client';
function getApiUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string })
.__PUBLIC_BACKEND_URL__;
if (injectedUrl) {
return `${injectedUrl}/api/v1`;
}
}
return 'http://localhost:3000/api/v1';
}
// IMPORTANT: Lazy initialization - don't create client at module level!
let _client: ReturnType<typeof createApiClient> | null = null;
function getClient() {
if (!_client) {
_client = createApiClient(getApiUrl());
}
return _client;
}
export async function getData() {
return getClient().get('/data');
}
```
### Template: Dockerfile (SvelteKit + pnpm)
```dockerfile
# Build stage
FROM node:20-alpine AS builder
RUN npm install -g pnpm@9.15.0
WORKDIR /app
# Copy workspace files
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/MYPROJECT/apps/web/package.json apps/MYPROJECT/apps/web/
COPY packages/ packages/
# Install all dependencies
RUN pnpm install --frozen-lockfile
# Copy source and build
COPY apps/MYPROJECT/apps/web apps/MYPROJECT/apps/web
RUN pnpm --filter @myproject/web build
# Production stage - PRESERVE PNPM SYMLINK STRUCTURE
FROM node:20-alpine AS production
# Keep same directory structure as builder
WORKDIR /app/apps/MYPROJECT/apps/web
# Copy pnpm store (target of symlinks)
COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm
# Copy app's node_modules (contains symlinks)
COPY --from=builder /app/apps/MYPROJECT/apps/web/node_modules ./node_modules
# Copy built app
COPY --from=builder /app/apps/MYPROJECT/apps/web/build ./build
COPY --from=builder /app/apps/MYPROJECT/apps/web/package.json ./
EXPOSE 5173
CMD ["node", "build"]
```
### Template: docker-compose.staging.yml Entry
```yaml
myproject-web:
image: ghcr.io/memo-2023/myproject-web:${MYPROJECT_WEB_VERSION:-latest}
container_name: myproject-web-staging
restart: unless-stopped
ports:
- '51XX:5173'
environment:
NODE_ENV: production
# Server-side URLs (Docker internal network)
PUBLIC_BACKEND_URL: http://myproject-backend:30XX
PUBLIC_MANA_CORE_AUTH_URL: http://mana-core-auth:3001
# Client-side URLs (browser access via public IP)
PUBLIC_BACKEND_URL_CLIENT: http://46.224.108.214:30XX
PUBLIC_MANA_CORE_AUTH_URL_CLIENT: http://46.224.108.214:3001
depends_on:
myproject-backend:
condition: service_healthy
healthcheck:
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:5173/health']
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- manacore-network
```
---
## 2. New NestJS Backend
### Checklist
- [ ] Use `text` type for all `user_id` columns (NOT `uuid`)
- [ ] Add health check endpoint at `/api/v1/health`
- [ ] Configure CORS to include manacore-web origin (port 5173)
- [ ] Add database to `docker/init-db/01-create-databases.sql`
- [ ] Add to `scripts/setup-databases.sh`
- [ ] Add `dev:myproject:full` command to root `package.json`
- [ ] Add Dockerfile with correct health check
- [ ] Add to `docker-compose.staging.yml` with proper CORS config
### Template: Drizzle Schema (user_id as text)
```typescript
// src/db/schema/main.schema.ts
import { pgTable, uuid, text, timestamp } from 'drizzle-orm/pg-core';
export const items = pgTable('items', {
id: uuid('id').defaultRandom().primaryKey(),
userId: text('user_id').notNull(), // ALWAYS text, not uuid!
title: text('title').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
```
### Template: Health Controller
```typescript
// src/health/health.controller.ts
import { Controller, Get } from '@nestjs/common';
@Controller('api/v1/health')
export class HealthController {
@Get()
check() {
return { status: 'ok', timestamp: new Date().toISOString() };
}
}
```
### Template: CORS Configuration
```typescript
// src/main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const corsOrigins = process.env.CORS_ORIGINS?.split(',') || [
'http://localhost:5173', // Local dev
'http://localhost:51XX', // App's own web
];
app.enableCors({
origin: corsOrigins,
credentials: true,
});
await app.listen(process.env.PORT || 30XX);
}
```
### Template: docker-compose.staging.yml Entry
```yaml
myproject-backend:
image: ghcr.io/memo-2023/myproject-backend:${MYPROJECT_BACKEND_VERSION:-latest}
container_name: myproject-backend-staging
restart: unless-stopped
ports:
- '30XX:30XX'
environment:
NODE_ENV: production
PORT: 30XX
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/myproject
MANA_CORE_AUTH_URL: http://mana-core-auth:3001
# CORS - Include app's web AND manacore-web dashboard
CORS_ORIGINS: http://46.224.108.214:51XX,http://46.224.108.214:5173,http://localhost:51XX,http://localhost:5173
depends_on:
postgres:
condition: service_healthy
mana-core-auth:
condition: service_healthy
healthcheck:
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:30XX/api/v1/health']
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- manacore-network
```
---
## 3. Deploying New Service to Staging
### Pre-Deployment Checklist
- [ ] Database exists on staging PostgreSQL
- [ ] Dockerfile has correct health check path (`/api/v1/health` for backends)
- [ ] `docker-compose.staging.yml` has service definition
- [ ] CORS_ORIGINS includes all required origins
- [ ] Environment variables set correctly
- [ ] Tag format matches project name exactly
### Create Database on Staging
```bash
ssh -i ~/.ssh/hetzner_deploy_key deploy@46.224.108.214
# Create database
docker exec manacore-postgres-staging psql -U postgres -c 'CREATE DATABASE myproject;'
# Verify
docker exec manacore-postgres-staging psql -U postgres -c '\l' | grep myproject
```
### Deployment Tag Formats
| Project | Correct Tag Format | Wrong Format |
|---------|-------------------|--------------|
| mana-core-auth | `mana-core-auth-staging-v1.0.X` | `auth-staging-v1.0.X` |
| chat | `chat-staging-v1.0.X` or `chat-all-staging-v1.0.X` | - |
| todo | `todo-staging-v1.0.X` or `todo-all-staging-v1.0.X` | - |
| calendar | `calendar-staging-v1.0.X` | - |
| clock | `clock-staging-v1.0.X` | - |
| myproject | `myproject-staging-v1.0.X` | - |
### Post-Deployment Verification
```bash
# Check container is running correct version
docker ps --format '{{.Names}}: {{.Image}}' | grep myproject
# Check health endpoint
curl http://46.224.108.214:30XX/api/v1/health
# Check logs for errors
docker logs myproject-backend-staging --tail 50
# Test CORS (from manacore-web origin)
curl -I -X OPTIONS http://46.224.108.214:30XX/api/v1/endpoint \
-H "Origin: http://46.224.108.214:5173" \
-H "Access-Control-Request-Method: GET"
```
---
## 4. Adding Backend to ManaCore Dashboard
When adding a new backend service that manacore-web dashboard should call:
### Checklist
- [ ] Add CORS origin for manacore-web (port 5173) to backend
- [ ] Create API service file in `manacore/apps/web/src/lib/api/services/`
- [ ] Add runtime URL injection in `manacore/apps/web/src/hooks.server.ts`
- [ ] Add environment variables to `docker-compose.staging.yml` for manacore-web
- [ ] Deploy both manacore-web and the backend with new config
### Template: API Service File
```typescript
// apps/manacore/apps/web/src/lib/api/services/myservice.ts
import { browser } from '$app/environment';
import { createApiClient, type ApiResult } from '../base-client';
function getMyServiceApiUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_MYSERVICE_API_URL__?: string })
.__PUBLIC_MYSERVICE_API_URL__;
if (injectedUrl) {
return `${injectedUrl}/api/v1`;
}
}
return 'http://localhost:30XX/api/v1';
}
let _client: ReturnType<typeof createApiClient> | null = null;
function getClient() {
if (!_client) {
_client = createApiClient(getMyServiceApiUrl());
}
return _client;
}
// Export API functions
export async function getItems(): Promise<ApiResult<Item[]>> {
return getClient().get('/items');
}
export async function createItem(data: CreateItemDto): Promise<ApiResult<Item>> {
return getClient().post('/items', data);
}
```
### Template: hooks.server.ts Addition
```typescript
// Add to existing hooks.server.ts
const PUBLIC_MYSERVICE_API_URL_CLIENT =
process.env.PUBLIC_MYSERVICE_API_URL_CLIENT || process.env.PUBLIC_MYSERVICE_API_URL || '';
// In transformPageChunk, add:
window.__PUBLIC_MYSERVICE_API_URL__ = "${PUBLIC_MYSERVICE_API_URL_CLIENT}";
```
### Template: docker-compose.staging.yml Addition
```yaml
manacore-web:
environment:
# ... existing env vars ...
# Add new backend URL
PUBLIC_MYSERVICE_API_URL: http://myservice-backend:30XX
PUBLIC_MYSERVICE_API_URL_CLIENT: http://46.224.108.214:30XX
```
---
## 5. Quick Reference Port Assignments
### Backend Ports (3000-3099)
| Port | Service |
|------|---------|
| 3000 | chat-web (legacy) |
| 3001 | mana-core-auth |
| 3002 | chat-backend |
| 3006 | picture-backend |
| 3007 | zitare-backend |
| 3009 | manadeck-backend |
| 3015 | contacts-backend |
| 3016 | calendar-backend |
| 3017 | clock-backend |
| 3018 | todo-backend |
### Web App Ports (5100-5199)
| Port | Service |
|------|---------|
| 5173 | manacore-web |
| 5175 | picture-web |
| 5177 | zitare-web |
| 5179 | calendar-web |
| 5184 | contacts-web |
| 5186 | calendar-web (staging) |
| 5187 | clock-web |
| 5188 | todo-web |
### Next Available Ports
- **Backend**: 3019, 3020, 3021...
- **Web**: 5189, 5190, 5191...
---
## Common Mistakes Quick Reference
| Mistake | Fix |
|---------|-----|
| `import.meta.env` in Docker | Use `window.__PUBLIC_*__` injection |
| API client at module level | Use lazy `getClient()` pattern |
| `uuid` type for user_id | Use `text` type |
| Missing CORS for 5173 | Add manacore-web to CORS_ORIGINS |
| `auth-staging-v*` tag | Use `mana-core-auth-staging-v*` |
| ALTER TABLE without USING | Use `USING column::text` |
| `/api/health` endpoint | Use `/api/v1/health` |

View file

@ -0,0 +1,408 @@
# Staging Deployment Issues & Solutions
This document captures common issues encountered during staging deployments and their solutions. Reference this when debugging deployment problems.
## Table of Contents
1. [Runtime Environment Variables (SvelteKit)](#1-runtime-environment-variables-sveltekit)
2. [CORS Configuration](#2-cors-configuration)
3. [CD Workflow Version Tags](#3-cd-workflow-version-tags)
4. [Database Setup](#4-database-setup)
5. [User ID Format (Better Auth)](#5-user-id-format-better-auth)
6. [Debugging Checklist](#6-debugging-checklist)
7. [Summary: Common Mistakes to Avoid](#summary-common-mistakes-to-avoid)
---
## 1. Runtime Environment Variables (SvelteKit)
### Problem
SvelteKit apps use `import.meta.env.PUBLIC_*` which gets **baked in at build time**. When running in Docker, the container uses whatever values were present during the GitHub Actions build, not the runtime environment variables.
**Symptoms:**
- Web apps calling `localhost:3001` instead of staging server IP
- API calls going to wrong URLs despite correct Docker env vars
### Solution
Use **runtime env injection** via `hooks.server.ts`:
```typescript
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
const PUBLIC_MANA_CORE_AUTH_URL_CLIENT =
process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || '';
const PUBLIC_BACKEND_URL_CLIENT =
process.env.PUBLIC_BACKEND_URL_CLIENT || '';
export const handle: Handle = async ({ event, resolve }) => {
return resolve(event, {
transformPageChunk: ({ html }) => {
const envScript = `<script>
window.__PUBLIC_MANA_CORE_AUTH_URL__ = "${PUBLIC_MANA_CORE_AUTH_URL_CLIENT}";
window.__PUBLIC_BACKEND_URL__ = "${PUBLIC_BACKEND_URL_CLIENT}";
</script>`;
return html.replace('<head>', `<head>${envScript}`);
},
});
};
```
Then in client code, read from `window` instead of `import.meta.env`:
```typescript
import { browser } from '$app/environment';
function getApiUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as any).__PUBLIC_BACKEND_URL__;
if (injectedUrl) return injectedUrl;
}
return 'http://localhost:3000'; // fallback for local dev
}
```
### Lazy Client Initialization Pattern
**Important**: API clients must be lazily initialized to read the URL at request time, not at module load time:
```typescript
// CORRECT - Lazy initialization
let _client: ReturnType<typeof createApiClient> | null = null;
function getClient() {
if (!_client) {
_client = createApiClient(getApiUrl()); // URL evaluated when called
}
return _client;
}
export async function getTasks() {
return getClient().get('/tasks'); // Client created on first use
}
```
```typescript
// WRONG - Module-level initialization
const client = createApiClient(getApiUrl()); // URL evaluated at import time!
export async function getTasks() {
return client.get('/tasks'); // Will use stale URL
}
```
**Why this matters**: When the module is imported, the `window` object may not have the injected environment variables yet. The lazy pattern ensures the URL is read only when the client is actually needed.
### Docker Compose Pattern
Use two environment variables:
- `PUBLIC_*_URL` - Internal Docker network URL (container-to-container)
- `PUBLIC_*_URL_CLIENT` - External URL for browser access
```yaml
environment:
PUBLIC_BACKEND_URL: http://backend-container:3000 # Server-side
PUBLIC_BACKEND_URL_CLIENT: http://46.224.108.214:3000 # Browser-side
```
---
## 2. CORS Configuration
### Problem
Backends only allow CORS from their own web apps, blocking requests from other origins like manacore-web dashboard.
**Symptoms:**
- `Access to fetch blocked by CORS policy`
- `No 'Access-Control-Allow-Origin' header`
### Solution
Add all necessary origins to `CORS_ORIGINS` in docker-compose.staging.yml:
```yaml
todo-backend:
environment:
# Include both the app's own web AND manacore-web dashboard
CORS_ORIGINS: http://46.224.108.214:5188,http://46.224.108.214:5173,http://localhost:5188,http://localhost:5173
```
### Checklist for New Backends
When deploying a new backend that will be called from manacore-web dashboard:
1. Add `http://46.224.108.214:5173` to CORS_ORIGINS
2. Add `http://localhost:5173` for local development
3. Restart the container after config changes
### Testing CORS
```bash
curl -I -X OPTIONS http://46.224.108.214:3018/api/v1/endpoint \
-H "Origin: http://46.224.108.214:5173" \
-H "Access-Control-Request-Method: GET"
# Should see:
# Access-Control-Allow-Origin: http://46.224.108.214:5173
```
---
## 3. CD Workflow Version Tags
### Problem
docker-compose uses variables like `${TODO_WEB_VERSION:-latest}`, but the CD workflow wasn't updating the `.env` file on the staging server, causing containers to always use `latest` instead of the tagged version.
**Symptoms:**
- Deployed new version but container still running old code
- `docker ps` shows wrong image tag
### Solution
The CD workflow (`.github/workflows/cd-staging-tagged.yml`) now:
1. Computes the version variable name (e.g., `TODO_WEB_VERSION`)
2. Updates the `.env` file on staging server
3. docker-compose reads from `.env`
### Tag Naming Convention
Tags must follow the exact project name as defined in the CD workflow:
| Project | Correct Tag Format | Wrong Format |
|---------|-------------------|--------------|
| mana-core-auth | `mana-core-auth-staging-v1.0.0` | `auth-staging-v1.0.0` |
| chat | `chat-staging-v1.0.0` or `chat-all-staging-v1.0.0` | - |
| todo | `todo-staging-v1.0.0` or `todo-all-staging-v1.0.0` | - |
**Note**: Using the wrong tag format (e.g., `auth-staging-*` instead of `mana-core-auth-staging-*`) will cause the workflow to fail because it won't find the correct Dockerfile path.
### Verifying Deployment
```bash
# Check running container version
docker ps --format '{{.Names}}: {{.Image}}' | grep todo
# Check .env file
cat ~/manacore-staging/.env | grep VERSION
```
---
## 4. Database Setup
### Problem
New backends fail with `database "X" does not exist` because the PostgreSQL databases weren't created.
**Symptoms:**
- 500 Internal Server Error
- Logs show: `PostgresError: database "todo" does not exist`
### Solution
Create databases manually on first deployment:
```bash
# SSH to staging
ssh deploy@46.224.108.214
# Create databases
docker exec manacore-postgres-staging psql -U postgres -c 'CREATE DATABASE todo;'
docker exec manacore-postgres-staging psql -U postgres -c 'CREATE DATABASE calendar;'
docker exec manacore-postgres-staging psql -U postgres -c 'CREATE DATABASE clock;'
# Restart backends (they auto-migrate schemas on startup)
cd ~/manacore-staging
docker compose restart todo-backend calendar-backend clock-backend
```
### Checklist for New Apps
When deploying a new app with a database:
1. Create the database: `CREATE DATABASE appname;`
2. The backend will auto-migrate the schema on startup
3. Verify tables exist: `\dt` in psql
---
## 5. User ID Format (Better Auth)
### Problem
Backend database schemas use `uuid` type for `user_id`, but Better Auth generates non-UUID user IDs like `otUe1YrfENPdHnrF3g1vSBfpkQfambCZ`.
**Symptoms:**
- 500 Internal Server Error on authenticated requests
- Logs show: `invalid input syntax for type uuid: "otUe1YrfENPdHnrF3g1vSBfpkQfambCZ"`
### Solution
Change `user_id` columns from `uuid` to `text`:
```sql
-- For each table with user_id (use USING clause for explicit conversion)
ALTER TABLE tasks ALTER COLUMN user_id TYPE text USING user_id::text;
ALTER TABLE projects ALTER COLUMN user_id TYPE text USING user_id::text;
-- etc.
```
**Important**: Always use the `USING` clause when converting column types. Without it, PostgreSQL may silently fail or produce unexpected results:
```sql
-- CORRECT - Explicit conversion
ALTER TABLE events ALTER COLUMN user_id TYPE text USING user_id::text;
-- RISKY - May fail silently on some data types
ALTER TABLE events ALTER COLUMN user_id TYPE text;
```
### Prevention
When creating new backend schemas, **always use `text` type for user_id**:
```typescript
// Drizzle schema - CORRECT
export const tasks = pgTable('tasks', {
id: uuid('id').defaultRandom().primaryKey(),
userId: text('user_id').notNull(), // Use text, not uuid
// ...
});
// WRONG - Don't do this
export const tasks = pgTable('tasks', {
userId: uuid('user_id').notNull(), // Will fail with Better Auth
});
```
---
## Quick Debugging Commands
```bash
# Check container logs
docker logs <container-name> --tail 50
# Check container is running correct version
docker ps --format '{{.Names}}: {{.Image}}'
# Test CORS
curl -I -X OPTIONS <url> -H "Origin: <origin>"
# Check database exists
docker exec manacore-postgres-staging psql -U postgres -c '\l'
# Check tables in database
docker exec manacore-postgres-staging psql -U postgres -d <dbname> -c '\dt'
# Restart a service
cd ~/manacore-staging && docker compose restart <service-name>
# Force recreate with new config
cd ~/manacore-staging && docker compose up -d --no-deps --force-recreate <service-name>
```
---
## Port Reference
| Service | Port |
|---------|------|
| mana-core-auth | 3001 |
| chat-backend | 3002 |
| calendar-backend | 3016 |
| clock-backend | 3017 |
| todo-backend | 3018 |
| chat-web | 3000 |
| manacore-web | 5173 |
| calendar-web | 5186 |
| clock-web | 5187 |
| todo-web | 5188 |
---
## 6. Debugging Checklist
When something doesn't work on staging, follow this checklist:
### API Returns Wrong Data or Fails
1. **Check if calling correct URL**
```bash
# In browser console
console.log(window.__PUBLIC_BACKEND_URL__)
```
If undefined or localhost, the runtime env injection isn't working.
2. **Check CORS**
```bash
curl -I -X OPTIONS http://46.224.108.214:<port>/api/v1/endpoint \
-H "Origin: http://46.224.108.214:5173"
```
Should return `Access-Control-Allow-Origin` header.
3. **Check container logs**
```bash
ssh deploy@46.224.108.214 "docker logs <container-name> --tail 100"
```
### 500 Internal Server Error
1. **Check database exists**
```bash
docker exec manacore-postgres-staging psql -U postgres -c '\l'
```
2. **Check tables exist**
```bash
docker exec manacore-postgres-staging psql -U postgres -d <dbname> -c '\dt'
```
3. **Check for type mismatches** (especially user_id uuid vs text)
### 401 Unauthorized
1. **Check token is being sent**
```bash
# In browser Network tab, check Authorization header
```
2. **Check JWKS endpoint**
```bash
curl http://46.224.108.214:3001/api/v1/auth/jwks
```
3. **Check issuer/audience match** - Token must have `iss: manacore` and `aud: manacore`
### Container Not Updated
1. **Check image version**
```bash
docker ps --format '{{.Names}}: {{.Image}}'
```
2. **Check .env file**
```bash
cat ~/manacore-staging/.env | grep VERSION
```
3. **Force recreate**
```bash
docker compose up -d --no-deps --force-recreate <service-name>
```
---
## Summary: Common Mistakes to Avoid
| Mistake | Consequence | Prevention |
|---------|-------------|------------|
| Using `import.meta.env` for Docker runtime | URLs baked at build time | Use `window.__PUBLIC_*__` with runtime injection |
| Initializing API clients at module level | Client uses stale URLs | Use lazy initialization pattern |
| Using `uuid` type for user_id | Better Auth IDs fail validation | Always use `text` type for user_id |
| Missing CORS origin for manacore-web | Dashboard can't call backends | Add port 5173 to all backend CORS configs |
| Wrong tag format for mana-core-auth | Deployment fails, can't find Dockerfile | Use `mana-core-auth-staging-v*` not `auth-staging-v*` |
| Forgetting to create database | Backend crashes on startup | Create database before first deployment |
| ALTER TABLE without USING clause | Silent failures on type conversion | Always use `USING column::new_type` |

View file

@ -17,6 +17,9 @@
"format": "prettier --config .prettierrc.json --write \"**/*.{ts,tsx,js,jsx,json,md,svelte,astro}\"",
"format:check": "prettier --config .prettierrc.json --check \"**/*.{ts,tsx,js,jsx,json,md,svelte,astro}\"",
"setup:env": "node scripts/generate-env.mjs",
"setup:db": "./scripts/setup-databases.sh",
"setup:db:chat": "./scripts/setup-databases.sh chat",
"setup:db:auth": "./scripts/setup-databases.sh auth",
"build:packages": "pnpm --filter '@manacore/*' build",
"postinstall": "node scripts/generate-env.mjs || true && pnpm run build:packages || true",
"manacore:dev": "turbo run dev --filter=manacore...",
@ -39,25 +42,28 @@
"dev:picture:mobile": "pnpm --filter @picture/mobile dev",
"dev:picture:backend": "pnpm --filter @picture/backend dev",
"dev:picture:app": "turbo run dev --filter=@picture/web --filter=@picture/backend",
"dev:picture:full": "./scripts/setup-databases.sh picture && ./scripts/setup-databases.sh auth && concurrently -n auth,backend,web -c blue,green,cyan \"pnpm dev:auth\" \"pnpm dev:picture:backend\" \"pnpm dev:picture:web\"",
"dev:chat:mobile": "pnpm --filter @chat/mobile dev",
"dev:chat:web": "pnpm --filter @chat/web dev",
"dev:chat:landing": "pnpm --filter @chat/landing dev",
"dev:chat:backend": "pnpm --filter @chat/backend start:dev",
"dev:chat:app": "turbo run dev --filter=@chat/web --filter=@chat/backend",
"dev:auth": "pnpm --filter mana-core-auth start:dev",
"dev:chat:full": "concurrently \"pnpm dev:auth\" \"pnpm dev:chat:backend\"",
"dev:chat:full": "./scripts/setup-databases.sh chat && ./scripts/setup-databases.sh auth && concurrently -n auth,backend,web -c blue,green,cyan \"pnpm dev:auth\" \"pnpm dev:chat:backend\" \"pnpm dev:chat:web\"",
"zitare:dev": "turbo run dev --filter=zitare...",
"dev:zitare:mobile": "pnpm --filter @zitare/mobile dev",
"dev:zitare:web": "pnpm --filter @zitare/web dev",
"dev:zitare:landing": "pnpm --filter @zitare/landing dev",
"dev:zitare:backend": "pnpm --filter @zitare/backend dev",
"dev:zitare:app": "turbo run dev --filter=@zitare/web --filter=@zitare/backend",
"dev:zitare:full": "./scripts/setup-databases.sh zitare && ./scripts/setup-databases.sh auth && concurrently -n auth,backend,web -c blue,green,cyan \"pnpm dev:auth\" \"pnpm dev:zitare:backend\" \"pnpm dev:zitare:web\"",
"contacts:dev": "turbo run dev --filter=contacts...",
"dev:contacts:mobile": "pnpm --filter @contacts/mobile dev",
"dev:contacts:web": "pnpm --filter @contacts/web dev",
"dev:contacts:landing": "pnpm --filter @contacts/landing dev",
"dev:contacts:backend": "pnpm --filter @contacts/backend dev",
"dev:contacts:app": "turbo run dev --filter=@contacts/web --filter=@contacts/backend",
"dev:contacts:full": "./scripts/setup-databases.sh contacts && ./scripts/setup-databases.sh auth && concurrently -n auth,backend,web -c blue,green,cyan \"pnpm dev:auth\" \"pnpm dev:contacts:backend\" \"pnpm dev:contacts:web\"",
"contacts:db:push": "pnpm --filter @contacts/backend db:push",
"contacts:db:studio": "pnpm --filter @contacts/backend db:studio",
"contacts:db:seed": "pnpm --filter @contacts/backend db:seed",
@ -67,6 +73,7 @@
"dev:calendar:landing": "pnpm --filter @calendar/landing dev",
"dev:calendar:backend": "pnpm --filter @calendar/backend dev",
"dev:calendar:app": "turbo run dev --filter=@calendar/web --filter=@calendar/backend",
"dev:calendar:full": "./scripts/setup-databases.sh calendar && ./scripts/setup-databases.sh auth && concurrently -n auth,backend,web -c blue,green,cyan \"pnpm dev:auth\" \"pnpm dev:calendar:backend\" \"pnpm dev:calendar:web\"",
"calendar:db:push": "pnpm --filter @calendar/backend db:push",
"calendar:db:studio": "pnpm --filter @calendar/backend db:studio",
"calendar:db:seed": "pnpm --filter @calendar/backend db:seed",
@ -75,6 +82,7 @@
"dev:clock:landing": "pnpm --filter @clock/landing dev",
"dev:clock:backend": "pnpm --filter @clock/backend dev",
"dev:clock:app": "turbo run dev --filter=@clock/web --filter=@clock/backend",
"dev:clock:full": "./scripts/setup-databases.sh clock && ./scripts/setup-databases.sh auth && concurrently -n auth,backend,web -c blue,green,cyan \"pnpm dev:auth\" \"pnpm dev:clock:backend\" \"pnpm dev:clock:web\"",
"clock:db:push": "pnpm --filter @clock/backend db:push",
"clock:db:studio": "pnpm --filter @clock/backend db:studio",
"clock:db:seed": "pnpm --filter @clock/backend db:seed",
@ -92,6 +100,7 @@
"dev:todo:landing": "pnpm --filter @todo/landing dev",
"dev:todo:backend": "pnpm --filter @todo/backend dev",
"dev:todo:app": "turbo run dev --filter=@todo/web --filter=@todo/backend",
"dev:todo:full": "./scripts/setup-databases.sh todo && ./scripts/setup-databases.sh auth && concurrently -n auth,backend,web -c blue,green,cyan \"pnpm dev:auth\" \"pnpm dev:todo:backend\" \"pnpm dev:todo:web\"",
"todo:db:push": "pnpm --filter @todo/backend db:push",
"todo:db:studio": "pnpm --filter @todo/backend db:studio",
"todo:db:seed": "pnpm --filter @todo/backend db:seed",

View file

@ -39,6 +39,7 @@ const DEFAULT_ENDPOINTS: AuthEndpoints = {
refresh: '/api/v1/auth/refresh',
validate: '/api/v1/auth/validate',
forgotPassword: '/api/v1/auth/forgot-password',
resetPassword: '/api/v1/auth/reset-password',
googleSignIn: '/api/v1/auth/google-signin',
appleSignIn: '/api/v1/auth/apple-signin',
credits: '/api/v1/credits/balance',
@ -192,6 +193,41 @@ export function createAuthService(config: AuthServiceConfig) {
}
},
/**
* Reset password with token
*/
async resetPassword(token: string, newPassword: string): Promise<AuthResult> {
try {
const response = await fetch(`${baseUrl}${endpoints.resetPassword}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, newPassword }),
});
if (!response.ok) {
const errorData = await response.json();
if (errorData.message?.includes('expired')) {
return { success: false, error: 'Reset link has expired. Please request a new one.' };
}
if (errorData.message?.includes('invalid')) {
return { success: false, error: 'Invalid reset link. Please request a new one.' };
}
return { success: false, error: errorData.message || 'Password reset failed' };
}
return { success: true };
} catch (error) {
console.error('Error resetting password:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during password reset',
};
}
},
/**
* Refresh the authentication tokens
*/

View file

@ -128,6 +128,7 @@ export interface AuthEndpoints {
refresh: string;
validate: string;
forgotPassword: string;
resetPassword: string;
googleSignIn: string;
appleSignIn: string;
credits: string;

View file

@ -31,6 +31,7 @@ export {
MailLogo,
MoodlitLogo,
InventoryLogo,
ClockLogo,
} from './logos';
// Configuration

View file

@ -0,0 +1,13 @@
<script lang="ts">
import AppLogo from '../AppLogo.svelte';
interface Props {
size?: number;
color?: string;
class?: string;
}
let { size = 55, color, class: className = '' }: Props = $props();
</script>
<AppLogo app="clock" {size} {color} class={className} />

View file

@ -18,3 +18,4 @@ export { default as TodoLogo } from './TodoLogo.svelte';
export { default as MailLogo } from './MailLogo.svelte';
export { default as MoodlitLogo } from './MoodlitLogo.svelte';
export { default as InventoryLogo } from './InventoryLogo.svelte';
export { default as ClockLogo } from './ClockLogo.svelte';

942
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

169
scripts/setup-databases.sh Executable file
View file

@ -0,0 +1,169 @@
#!/bin/bash
# Setup script for creating databases and pushing schemas
# Usage: ./scripts/setup-databases.sh [service]
# Examples:
# ./scripts/setup-databases.sh # Setup all
# ./scripts/setup-databases.sh chat # Setup only chat
# ./scripts/setup-databases.sh auth # Setup only auth
set -e
# Database connection details (from .env.development)
DB_HOST="${DB_HOST:-localhost}"
DB_PORT="${DB_PORT:-5432}"
DB_USER="${POSTGRES_USER:-manacore}"
DB_PASSWORD="${POSTGRES_PASSWORD:-devpassword}"
# Colors for output
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
echo -e "${GREEN}🗄️ Database Setup Script${NC}"
echo "======================================"
# Function to create database if it doesn't exist
create_db_if_not_exists() {
local db_name=$1
echo -e "${YELLOW}Checking database: ${db_name}${NC}"
if PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d postgres -tc \
"SELECT 1 FROM pg_database WHERE datname = '$db_name'" | grep -q 1; then
echo -e " ${GREEN}✓ Exists${NC}"
else
echo -e " Creating database ${db_name}..."
PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d postgres -c "CREATE DATABASE $db_name;" > /dev/null
PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d postgres -c "GRANT ALL PRIVILEGES ON DATABASE $db_name TO $DB_USER;" > /dev/null
echo -e " ${GREEN}✓ Created${NC}"
fi
}
# Function to push schema for a service
push_schema() {
local filter=$1
local name=$2
echo -e "${YELLOW}Pushing schema for ${name}...${NC}"
# Use --force to auto-approve in development (skips interactive prompts)
if pnpm --filter "$filter" db:push --force 2>/dev/null; then
echo -e " ${GREEN}✓ Schema pushed${NC}"
else
echo -e " ${RED}✗ Failed (may not have db:push script)${NC}"
fi
}
# All databases that should exist
ALL_DATABASES=(
"manacore"
"chat"
"zitare"
"contacts"
"calendar"
"clock"
"todo"
"manadeck"
"storage"
"mail"
"moodlit"
"finance"
"inventory"
"techbase"
"voxel_lava"
"figgos"
)
# Check if specific service requested
SERVICE_FILTER=${1:-""}
setup_service() {
local service=$1
case $service in
auth|mana-core-auth)
create_db_if_not_exists "manacore"
push_schema "mana-core-auth" "mana-core-auth"
;;
chat)
create_db_if_not_exists "chat"
push_schema "@chat/backend" "chat"
;;
zitare)
create_db_if_not_exists "zitare"
push_schema "@zitare/backend" "zitare"
;;
contacts)
create_db_if_not_exists "contacts"
push_schema "@contacts/backend" "contacts"
;;
calendar)
create_db_if_not_exists "calendar"
push_schema "@calendar/backend" "calendar"
;;
clock)
create_db_if_not_exists "clock"
push_schema "@clock/backend" "clock"
;;
todo)
create_db_if_not_exists "todo"
push_schema "@todo/backend" "todo"
;;
manadeck)
create_db_if_not_exists "manadeck"
push_schema "@manadeck/backend" "manadeck"
;;
mail)
create_db_if_not_exists "mail"
push_schema "@mail/backend" "mail"
;;
moodlit)
create_db_if_not_exists "moodlit"
push_schema "@moodlit/backend" "moodlit"
;;
picture)
create_db_if_not_exists "picture"
push_schema "@picture/backend" "picture"
;;
finance)
create_db_if_not_exists "finance"
push_schema "@finance/backend" "finance"
;;
voxel-lava)
create_db_if_not_exists "voxel_lava"
push_schema "@voxel-lava/backend" "voxel-lava"
;;
figgos)
create_db_if_not_exists "figgos"
push_schema "@figgos/backend" "figgos"
;;
*)
echo -e "${RED}Unknown service: $service${NC}"
echo "Available services: auth, chat, zitare, contacts, calendar, clock, todo, manadeck, mail, moodlit, finance, voxel-lava, figgos"
exit 1
;;
esac
}
if [ -n "$SERVICE_FILTER" ]; then
echo -e "Setting up for service: ${SERVICE_FILTER}"
setup_service "$SERVICE_FILTER"
echo -e "\n${GREEN}✓ Setup complete!${NC}"
exit 0
fi
# Setup all databases
echo -e "\n${GREEN}Step 1: Creating databases${NC}"
echo "--------------------------------------"
for db in "${ALL_DATABASES[@]}"; do
create_db_if_not_exists "$db"
done
echo -e "\n${GREEN}Step 2: Pushing schemas${NC}"
echo "--------------------------------------"
# Push schemas for all known services
for service in auth chat zitare contacts calendar clock todo manadeck picture mail moodlit finance voxel-lava figgos; do
setup_service "$service" 2>/dev/null || true
done
echo -e "\n${GREEN}✓ Database setup complete!${NC}"

View file

@ -91,7 +91,9 @@ services/mana-core-auth/
│ ├── credits/ # Credit system
│ ├── db/
│ │ ├── schema/ # Drizzle schemas
│ │ └── connection.ts # DB connection
│ │ ├── migrations/ # Generated migration files
│ │ ├── connection.ts # DB connection
│ │ └── migrate.ts # Migration script with advisory locks
│ └── config/
│ └── configuration.ts # App config
├── docs/
@ -99,6 +101,16 @@ services/mana-core-auth/
└── test/
```
## Database Migrations
For comprehensive migration documentation, see **[docs/DATABASE_MIGRATIONS.md](/docs/DATABASE_MIGRATIONS.md)**.
Key points:
- Use `db:push` for development (fast iteration)
- Use `db:generate` + `db:migrate` for production (tracked migrations)
- Migrations use advisory locks to prevent concurrent execution
- CI/CD runs migrations automatically before code deployment
## Key Files
| File | Purpose |

View file

@ -16,6 +16,8 @@
"test:cov": "jest --coverage",
"test:e2e": "jest --config ./test/jest-e2e.json",
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/db/migrate.ts",
"db:studio": "drizzle-kit studio"
},
"dependencies": {

View file

@ -18,6 +18,8 @@ import { RegisterB2BDto } from './dto/register-b2b.dto';
import { InviteEmployeeDto } from './dto/invite-employee.dto';
import { AcceptInvitationDto } from './dto/accept-invitation.dto';
import { SetActiveOrganizationDto } from './dto/set-active-organization.dto';
import { ForgotPasswordDto } from './dto/forgot-password.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
/**
@ -137,6 +139,39 @@ export class AuthController {
return this.betterAuthService.getJwks();
}
// =========================================================================
// Password Reset Endpoints
// =========================================================================
/**
* Request password reset
*
* Initiates the password reset flow by sending an email with a reset link.
* Always returns success to prevent email enumeration attacks.
*/
@Post('forgot-password')
@HttpCode(HttpStatus.OK)
async forgotPassword(@Body() forgotPasswordDto: ForgotPasswordDto) {
return this.betterAuthService.requestPasswordReset(
forgotPasswordDto.email,
forgotPasswordDto.redirectTo
);
}
/**
* Reset password with token
*
* Completes the password reset using the token from the email link.
*/
@Post('reset-password')
@HttpCode(HttpStatus.OK)
async resetPassword(@Body() resetPasswordDto: ResetPasswordDto) {
return this.betterAuthService.resetPassword(
resetPasswordDto.token,
resetPasswordDto.newPassword
);
}
// =========================================================================
// B2B Registration
// =========================================================================

View file

@ -80,12 +80,36 @@ export function createBetterAuth(databaseUrl: string) {
},
}),
// Email/password authentication only
// Email/password authentication with password reset
emailAndPassword: {
enabled: true,
requireEmailVerification: false, // Can enable later
minPasswordLength: 12,
maxPasswordLength: 128,
/**
* Password Reset Configuration
*
* Better Auth provides password reset via:
* - auth.api.forgetPassword({ email }) - Sends reset email
* - auth.api.resetPassword({ newPassword, token }) - Resets password
*
* @see https://www.better-auth.com/docs/authentication/email-password#password-reset
*/
sendResetPassword: async ({ user, url, token }) => {
// TODO: Implement email sending service (e.g., Resend, SendGrid)
// For now, log the reset URL for development
console.log('[Password Reset] User:', user.email);
console.log('[Password Reset] Reset URL:', url);
console.log('[Password Reset] Token:', token);
// In production, send an email like:
// await sendEmail({
// to: user.email,
// subject: 'Reset your password',
// html: `<a href="${url}">Reset your password</a>`
// });
},
},
// Session configuration

View file

@ -0,0 +1,22 @@
import { IsEmail, IsOptional, IsString, IsUrl } from 'class-validator';
/**
* Forgot Password DTO
*
* Request body for initiating password reset.
*/
export class ForgotPasswordDto {
/**
* User's email address
*/
@IsEmail()
email: string;
/**
* Optional redirect URL after password reset
* The reset token will be appended as a query parameter
*/
@IsOptional()
@IsString()
redirectTo?: string;
}

View file

@ -0,0 +1,22 @@
import { IsString, MinLength, MaxLength } from 'class-validator';
/**
* Reset Password DTO
*
* Request body for resetting password with token.
*/
export class ResetPasswordDto {
/**
* Reset token from email link
*/
@IsString()
token: string;
/**
* New password (must meet password requirements)
*/
@IsString()
@MinLength(12, { message: 'Password must be at least 12 characters long' })
@MaxLength(128, { message: 'Password must be at most 128 characters long' })
newPassword: string;
}

View file

@ -845,6 +845,92 @@ export class BetterAuthService {
}
}
// =========================================================================
// Password Reset Methods
// =========================================================================
/**
* Request password reset
*
* Sends a password reset email to the user.
* Uses Better Auth's forgetPassword API.
*
* @param email - User's email address
* @param redirectTo - Optional URL to redirect after reset (used in email link)
* @returns Success status
*/
async requestPasswordReset(
email: string,
redirectTo?: string
): Promise<{ success: boolean; message: string }> {
try {
// Better Auth's forgetPassword method
// See: https://www.better-auth.com/docs/authentication/email-password#password-reset
await (this.auth.api as any).forgetPassword({
body: {
email,
redirectTo,
},
});
// Always return success to prevent email enumeration
return {
success: true,
message: 'If an account with that email exists, a password reset link has been sent',
};
} catch (error) {
console.error('[requestPasswordReset] Error:', error);
// Always return success to prevent email enumeration attacks
return {
success: true,
message: 'If an account with that email exists, a password reset link has been sent',
};
}
}
/**
* Reset password with token
*
* Resets the user's password using the token from the reset email.
* Uses Better Auth's resetPassword API.
*
* @param token - Reset token from email link
* @param newPassword - New password to set
* @returns Success status
* @throws UnauthorizedException if token is invalid or expired
*/
async resetPassword(
token: string,
newPassword: string
): Promise<{ success: boolean; message: string }> {
try {
// Better Auth's resetPassword method
// See: https://www.better-auth.com/docs/authentication/email-password#password-reset
await (this.auth.api as any).resetPassword({
body: {
token,
newPassword,
},
});
return {
success: true,
message: 'Password has been reset successfully',
};
} catch (error: unknown) {
if (error instanceof Error) {
if (
error.message?.includes('invalid') ||
error.message?.includes('expired') ||
error.message?.includes('not found')
) {
throw new UnauthorizedException('Invalid or expired reset token');
}
}
throw error;
}
}
/**
* Get JWKS (JSON Web Key Set)
*

View file

@ -5,7 +5,7 @@ import {
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { jwtVerify, createRemoteJWKSet, type JWTPayload } from 'jose';
import { jwtVerify, createRemoteJWKSet } from 'jose';
/**
* JWT Auth Guard using JWKS (Better Auth compatible)
@ -23,7 +23,10 @@ export class JwtAuthGuard implements CanActivate {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
console.log('[JwtAuthGuard] Token (first 50 chars):', token?.substring(0, 50));
if (!token) {
console.log('[JwtAuthGuard] No token provided');
throw new UnauthorizedException('No token provided');
}
@ -32,17 +35,22 @@ export class JwtAuthGuard implements CanActivate {
if (!this.jwks) {
const baseUrl = this.configService.get<string>('BASE_URL') || 'http://localhost:3001';
const jwksUrl = new URL('/api/v1/auth/jwks', baseUrl);
console.log('[JwtAuthGuard] Initializing JWKS from:', jwksUrl.toString());
this.jwks = createRemoteJWKSet(jwksUrl);
}
const issuer = this.configService.get<string>('jwt.issuer') || 'manacore';
const audience = this.configService.get<string>('jwt.audience') || 'manacore';
console.log('[JwtAuthGuard] Verifying with issuer:', issuer, 'audience:', audience);
const { payload } = await jwtVerify(token, this.jwks, {
issuer,
audience,
});
console.log('[JwtAuthGuard] Verification SUCCESS, user:', payload.sub);
// Attach user to request
request.user = {
userId: payload.sub,
@ -52,7 +60,7 @@ export class JwtAuthGuard implements CanActivate {
return true;
} catch (error) {
console.debug('[JwtAuthGuard] Token verification failed:', error);
console.error('[JwtAuthGuard] Token verification FAILED:', error);
throw new UnauthorizedException('Invalid token');
}
}

View file

@ -0,0 +1,222 @@
/**
* Database Migration Script with Advisory Locks
*
* This script safely runs database migrations with the following features:
* - Advisory locks to prevent concurrent migrations
* - Retry logic for transient network failures
* - Timeout protection
* - Proper cleanup on exit
* - Graceful handling when no migrations exist
*
* Usage:
* pnpm db:migrate # Run migrations
* MIGRATION_TIMEOUT=600 pnpm db:migrate # With custom timeout (seconds)
*/
import { drizzle } from 'drizzle-orm/postgres-js';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import { sql } from 'drizzle-orm';
import postgres from 'postgres';
import * as dotenv from 'dotenv';
import * as fs from 'fs';
import * as path from 'path';
// Load environment variables
dotenv.config();
// Configuration
const MIGRATION_LOCK_ID = 987654321; // Unique lock ID for mana-core-auth migrations
const MAX_LOCK_WAIT_MS = parseInt(process.env.MIGRATION_TIMEOUT || '300', 10) * 1000; // Default 5 minutes
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 2000;
/**
* Retry wrapper for transient errors
*/
async function withRetry<T>(
operation: () => Promise<T>,
operationName: string,
maxRetries = MAX_RETRIES
): Promise<T> {
let lastError: Error | undefined;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error as Error;
// Check if error is transient (network-related)
const isTransient =
lastError.message?.includes('ECONNREFUSED') ||
lastError.message?.includes('ETIMEDOUT') ||
lastError.message?.includes('ENOTFOUND') ||
lastError.message?.includes('connection') ||
(lastError as any).code === '57P03'; // PostgreSQL: cannot connect now
if (!isTransient || attempt === maxRetries) {
throw error;
}
const delay = RETRY_DELAY_MS * Math.pow(2, attempt - 1); // Exponential backoff
console.log(
`\u26a0\ufe0f [${operationName}] Transient error, retrying in ${delay}ms... (attempt ${attempt}/${maxRetries})`
);
console.log(` Error: ${lastError.message}`);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw lastError!;
}
/**
* Acquire PostgreSQL advisory lock
*/
async function acquireLock(db: ReturnType<typeof drizzle>): Promise<boolean> {
const result = await db.execute(
sql`SELECT pg_try_advisory_lock(${MIGRATION_LOCK_ID}) as acquired`
);
return (result as any)[0]?.acquired === true;
}
/**
* Release PostgreSQL advisory lock
*/
async function releaseLock(db: ReturnType<typeof drizzle>): Promise<void> {
await db.execute(sql`SELECT pg_advisory_unlock(${MIGRATION_LOCK_ID})`);
}
/**
* Wait for migration lock with timeout
*/
async function waitForLock(db: ReturnType<typeof drizzle>): Promise<boolean> {
const startTime = Date.now();
while (Date.now() - startTime < MAX_LOCK_WAIT_MS) {
const acquired = await acquireLock(db);
if (acquired) {
return true;
}
const elapsed = Math.round((Date.now() - startTime) / 1000);
console.log(`\u23f3 Waiting for migration lock... (${elapsed}s / ${MAX_LOCK_WAIT_MS / 1000}s)`);
await new Promise((resolve) => setTimeout(resolve, 5000));
}
return false;
}
/**
* Main migration function
*/
async function runMigrations(): Promise<void> {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
console.log('\n\ud83d\udd04 Starting database migration process...');
console.log(` Lock ID: ${MIGRATION_LOCK_ID}`);
console.log(` Timeout: ${MAX_LOCK_WAIT_MS / 1000}s`);
console.log('');
// Create connection with single connection for migrations
const connection = postgres(databaseUrl, {
max: 1,
idle_timeout: 20,
connect_timeout: 30,
});
const db = drizzle(connection);
let lockAcquired = false;
try {
// Test database connection
console.log('\ud83d\udd0c Testing database connection...');
await withRetry(async () => {
await db.execute(sql`SELECT 1`);
}, 'Database connection');
console.log('\u2705 Database connection successful\n');
// Attempt to acquire advisory lock
console.log('\ud83d\udd12 Attempting to acquire migration lock...');
lockAcquired = await withRetry(() => acquireLock(db), 'Acquire lock');
if (!lockAcquired) {
console.log('\u23f3 Another instance is running migrations. Waiting for lock...');
lockAcquired = await waitForLock(db);
if (!lockAcquired) {
throw new Error(
`Migration lock timeout after ${MAX_LOCK_WAIT_MS / 1000}s - another migration may be stuck`
);
}
}
console.log('\u2705 Migration lock acquired\n');
// Check if migration files exist
const migrationsFolder = './src/db/migrations';
const journalPath = path.join(migrationsFolder, 'meta', '_journal.json');
if (!fs.existsSync(journalPath)) {
console.log('\u26a0\ufe0f No migration files found (meta/_journal.json missing)');
console.log(' This is normal if you have not generated any migrations yet.');
console.log(' To generate migrations, run: pnpm db:generate');
console.log(' For development, you can use: pnpm db:push');
console.log('\n\u2705 No migrations to run\n');
return;
}
// Run migrations
console.log('\ud83d\udce6 Running database migrations...');
await withRetry(
async () => {
await migrate(db, {
migrationsFolder,
});
},
'Run migrations',
1 // Only 1 attempt for actual migrations (they should be idempotent)
);
console.log('\u2705 Migrations completed successfully\n');
} catch (error) {
console.error('\n\u274c Migration failed:', error);
throw error;
} finally {
// Always attempt to release lock
if (lockAcquired) {
try {
await releaseLock(db);
console.log('\ud83d\udd13 Migration lock released');
} catch (unlockError) {
console.error('\u26a0\ufe0f Failed to release lock:', unlockError);
}
}
// Close connection
try {
await connection.end();
console.log('\ud83d\udd0c Database connection closed\n');
} catch (closeError) {
console.error('\u26a0\ufe0f Failed to close connection:', closeError);
}
}
}
// Run migrations
runMigrations()
.then(() => {
console.log('\ud83c\udf89 Migration process completed successfully');
process.exit(0);
})
.catch((error) => {
console.error('\n\ud83d\udca5 Migration process failed:', error.message);
process.exit(1);
});

View file

@ -1,39 +0,0 @@
CREATE SCHEMA "feedback";
--> statement-breakpoint
CREATE TYPE "public"."feedback_category" AS ENUM('bug', 'feature', 'improvement', 'question', 'other');--> statement-breakpoint
CREATE TYPE "public"."feedback_status" AS ENUM('submitted', 'under_review', 'planned', 'in_progress', 'completed', 'declined');--> statement-breakpoint
CREATE TABLE "feedback"."feedback_votes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"feedback_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "feedback"."user_feedback" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"app_id" text NOT NULL,
"title" text,
"feedback_text" text NOT NULL,
"category" "feedback_category" DEFAULT 'feature' NOT NULL,
"status" "feedback_status" DEFAULT 'submitted' NOT NULL,
"is_public" boolean DEFAULT false NOT NULL,
"admin_response" text,
"vote_count" integer DEFAULT 0 NOT NULL,
"device_info" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"published_at" timestamp with time zone,
"completed_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "feedback"."feedback_votes" ADD CONSTRAINT "feedback_votes_feedback_id_user_feedback_id_fk" FOREIGN KEY ("feedback_id") REFERENCES "feedback"."user_feedback"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "feedback"."feedback_votes" ADD CONSTRAINT "feedback_votes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "feedback"."user_feedback" ADD CONSTRAINT "user_feedback_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "feedback_vote_unique" ON "feedback"."feedback_votes" USING btree ("feedback_id","user_id");--> statement-breakpoint
CREATE INDEX "feedback_votes_feedback_idx" ON "feedback"."feedback_votes" USING btree ("feedback_id");--> statement-breakpoint
CREATE INDEX "feedback_user_idx" ON "feedback"."user_feedback" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "feedback_app_idx" ON "feedback"."user_feedback" USING btree ("app_id");--> statement-breakpoint
CREATE INDEX "feedback_public_idx" ON "feedback"."user_feedback" USING btree ("is_public");--> statement-breakpoint
CREATE INDEX "feedback_status_idx" ON "feedback"."user_feedback" USING btree ("status");--> statement-breakpoint
CREATE INDEX "feedback_created_at_idx" ON "feedback"."user_feedback" USING btree ("created_at");