chore: convert to Turborepo + npm workspaces monorepo

- Move React/Vite frontend to apps/web/ (@budgetwise/web)
- Add apps/appwrite/ (@budgetwise/appwrite) to source-control the
  Appwrite backend: declarative schema in appwrite.json (5 collections),
  CLI-based deploy.sh for containerized use, functions/ dir for future
  Appwrite Functions
- Add turbo.json for task orchestration (build, deploy, dev)
- Replace .gitlab-ci.yml with Woodpecker CI pipelines in .woodpecker/:
    web-production.yml  — push to main → build + rsync to prod
    web-staging.yml     — push to staging → build + rsync to staging
    web-preview.yml     — PR open → deploy to {pr}.{domain}; PR close → cleanup
    appwrite.yml        — schema changes in apps/appwrite/ → CLI deploy
- All secrets injected via Woodpecker CI (no committed .env files)
This commit is contained in:
Kushal Gaywala
2026-02-28 19:16:26 +01:00
parent 8009c11581
commit e0e0cc65f1
49 changed files with 729 additions and 226 deletions

58
apps/appwrite/scripts/deploy.sh Executable file
View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# BudgetWise Appwrite Deploy Script
#
# Deploys the schema defined in appwrite.json (databases, collections) using
# the Appwrite CLI. Runs functions deploy if functions/ contains any functions.
#
# Works locally and in CI (Woodpecker, Docker, etc.) — credentials come from
# environment variables, never from committed files.
#
# Required environment variables:
# APPWRITE_ENDPOINT e.g. https://appwrite.example.com/v1
# APPWRITE_PROJECT_ID The Appwrite project ID
# APPWRITE_API_KEY API key with databases.write + collections.write scopes
#
# Local usage:
# export APPWRITE_ENDPOINT=... APPWRITE_PROJECT_ID=... APPWRITE_API_KEY=...
# bash scripts/deploy.sh
#
# Or with a local .env file at apps/appwrite/.env:
# set -a && source .env && set +a && bash scripts/deploy.sh
# ─────────────────────────────────────────────────────────────────────────────
set -euo pipefail
APPWRITE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CLI="npx --yes appwrite@6"
# ── Validate required env vars ────────────────────────────────────────────────
: "${APPWRITE_ENDPOINT:?APPWRITE_ENDPOINT is required}"
: "${APPWRITE_PROJECT_ID:?APPWRITE_PROJECT_ID is required}"
: "${APPWRITE_API_KEY:?APPWRITE_API_KEY is required}"
echo "==> Deploying Appwrite schema"
echo " Endpoint : $APPWRITE_ENDPOINT"
echo " Project : $APPWRITE_PROJECT_ID"
# ── Configure CLI session ─────────────────────────────────────────────────────
# Writes to ~/.appwrite/prefs.json — ephemeral in CI runners.
$CLI client \
--endpoint "$APPWRITE_ENDPOINT" \
--project-id "$APPWRITE_PROJECT_ID" \
--key "$APPWRITE_API_KEY"
# ── Deploy databases + collections ────────────────────────────────────────────
cd "$APPWRITE_DIR"
echo "==> Deploying databases..."
$CLI deploy database --all --yes
# ── Deploy functions (skipped if functions/ is empty) ─────────────────────────
if [ -d "functions" ] && [ -n "$(ls -A functions 2>/dev/null | grep -v '\.gitkeep')" ]; then
echo "==> Deploying functions..."
$CLI deploy function --all --yes
else
echo "==> No functions to deploy, skipping."
fi
echo "==> Appwrite deploy complete."

View File

@@ -0,0 +1,245 @@
#!/usr/bin/env node
/**
* BudgetWise Appwrite database setup script
*
* Run once to create the database, collections, and attributes
* in your self-hosted Appwrite instance.
*
* Usage:
* cp .env.example .env
* # Fill in VITE_APPWRITE_ENDPOINT, VITE_APPWRITE_PROJECT_ID, APPWRITE_API_KEY
* npm run setup:appwrite
*/
const { Client, Databases, Permission, Role, IndexType } = require('node-appwrite');
const fs = require('fs');
const path = require('path');
// ── Load .env manually (no dotenv dependency needed) ─────────────────────────
function loadEnv() {
const envPath = path.join(__dirname, '..', '.env');
if (!fs.existsSync(envPath)) {
console.error('❌ .env file not found. Copy .env.example from the repo root to apps/appwrite/.env and fill in the values.');
process.exit(1);
}
const lines = fs.readFileSync(envPath, 'utf8').split('\n');
const env = {};
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const [key, ...rest] = trimmed.split('=');
env[key.trim()] = rest.join('=').trim();
}
return env;
}
const env = loadEnv();
const ENDPOINT = env.VITE_APPWRITE_ENDPOINT;
const PROJECT_ID = env.VITE_APPWRITE_PROJECT_ID;
const API_KEY = env.APPWRITE_API_KEY;
if (!ENDPOINT || !PROJECT_ID || !API_KEY) {
console.error('❌ Missing VITE_APPWRITE_ENDPOINT, VITE_APPWRITE_PROJECT_ID, or APPWRITE_API_KEY in .env');
process.exit(1);
}
const client = new Client()
.setEndpoint(ENDPOINT)
.setProject(PROJECT_ID)
.setKey(API_KEY);
const databases = new Databases(client);
const DB_ID = 'budget_db';
const DB_NAME = 'BudgetWise';
// ── Permissions: only authenticated users can access their own docs ──────────
// We rely on document-level security with user_id filtering.
const PERMISSIONS = [
Permission.read(Role.users()),
Permission.create(Role.users()),
Permission.update(Role.users()),
Permission.delete(Role.users()),
];
async function createCollection(id, name, attrs, indexes = []) {
console.log(`\n📁 Creating collection: ${name}`);
try {
await databases.createCollection(DB_ID, id, name, PERMISSIONS);
console.log(` ✓ Collection created`);
} catch (err) {
if (err.code === 409) {
console.log(` ⚠ Collection already exists, skipping...`);
} else {
throw err;
}
}
// Add attributes
for (const attr of attrs) {
try {
if (attr.type === 'string') {
await databases.createStringAttribute(DB_ID, id, attr.key, attr.size ?? 255, attr.required ?? false, attr.default ?? null);
} else if (attr.type === 'float') {
await databases.createFloatAttribute(DB_ID, id, attr.key, attr.required ?? false, undefined, undefined, attr.default ?? null);
} else if (attr.type === 'integer') {
await databases.createIntegerAttribute(DB_ID, id, attr.key, attr.required ?? false, undefined, undefined, attr.default ?? null);
} else if (attr.type === 'boolean') {
await databases.createBooleanAttribute(DB_ID, id, attr.key, attr.required ?? false, attr.default ?? null);
}
process.stdout.write(`${attr.key}\n`);
// Wait a bit between attributes (Appwrite rate limits)
await sleep(300);
} catch (err) {
if (err.code === 409) {
process.stdout.write(`${attr.key} (already exists)\n`);
} else {
console.error(`${attr.key}: ${err.message}`);
}
}
}
// Add indexes
for (const idx of indexes) {
try {
await databases.createIndex(DB_ID, id, idx.key, IndexType.Key, idx.attributes, idx.orders ?? []);
process.stdout.write(` ✓ index: ${idx.key}\n`);
await sleep(300);
} catch (err) {
if (err.code === 409) {
process.stdout.write(` ⚠ index ${idx.key} (already exists)\n`);
} else {
console.error(` ❌ index ${idx.key}: ${err.message}`);
}
}
}
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
async function main() {
console.log('🚀 BudgetWise Appwrite Setup');
console.log(` Endpoint : ${ENDPOINT}`);
console.log(` Project : ${PROJECT_ID}`);
// Create database
console.log('\n🗄 Creating database...');
try {
await databases.create(DB_ID, DB_NAME);
console.log(' ✓ Database created');
} catch (err) {
if (err.code === 409) {
console.log(' ⚠ Database already exists, continuing...');
} else {
throw err;
}
}
// ── balance_sheets ────────────────────────────────────────────────────────
await createCollection(
'balance_sheets',
'Balance Sheets',
[
{ type: 'integer', key: 'month', required: true },
{ type: 'integer', key: 'year', required: true },
{ type: 'string', key: 'buffer_type', required: true, size: 20 },
{ type: 'float', key: 'buffer_value', required: true, default: 0 },
{ type: 'string', key: 'user_id', required: true, size: 64 },
],
[
{ key: 'user_month_year', attributes: ['user_id', 'month', 'year'], orders: ['ASC', 'DESC', 'DESC'] },
],
);
// ── incomes ───────────────────────────────────────────────────────────────
await createCollection(
'incomes',
'Incomes',
[
{ type: 'string', key: 'balance_sheet_id', required: true, size: 64 },
{ type: 'string', key: 'name', required: true, size: 128 },
{ type: 'float', key: 'amount', required: true },
{ type: 'string', key: 'frequency', required: true, size: 20 },
{ type: 'string', key: 'user_id', required: true, size: 64 },
],
[
{ key: 'user_sheet', attributes: ['user_id', 'balance_sheet_id'] },
],
);
// ── buckets ───────────────────────────────────────────────────────────────
await createCollection(
'buckets',
'Buckets',
[
{ type: 'string', key: 'name', required: true, size: 128 },
{ type: 'string', key: 'description', required: false, size: 512, default: '' },
{ type: 'string', key: 'type', required: true, size: 32 },
{ type: 'float', key: 'current_balance', required: true, default: 0 },
{ type: 'float', key: 'goal_amount', required: false, default: 0 },
{ type: 'string', key: 'goal_type', required: false, size: 20, default: 'amount' },
{ type: 'string', key: 'goal_frequency', required: false, size: 20, default: 'monthly' },
{ type: 'float', key: 'return_percent', required: false, default: 0 },
{ type: 'string', key: 'return_frequency', required: false, size: 20, default: 'yearly' },
{ type: 'string', key: 'color', required: true, size: 16 },
{ type: 'integer', key: 'sort_order', required: false, default: 0 },
{ type: 'string', key: 'user_id', required: true, size: 64 },
],
[
{ key: 'user_order', attributes: ['user_id', 'sort_order'], orders: ['ASC', 'ASC'] },
],
);
// ── debts ─────────────────────────────────────────────────────────────────
await createCollection(
'debts',
'Debts',
[
{ type: 'string', key: 'name', required: true, size: 128 },
{ type: 'float', key: 'principal', required: true },
{ type: 'float', key: 'remaining_balance', required: true },
{ type: 'float', key: 'interest_rate', required: true },
{ type: 'string', key: 'interest_frequency', required: true, size: 20 },
{ type: 'integer', key: 'term_months', required: true },
{ type: 'float', key: 'monthly_payment', required: true },
{ type: 'boolean', key: 'is_auto_calculated', required: true, default: false },
{ type: 'string', key: 'start_date', required: true, size: 24 },
{ type: 'string', key: 'user_id', required: true, size: 64 },
],
[
{ key: 'user_id_idx', attributes: ['user_id'] },
],
);
// ── transactions ──────────────────────────────────────────────────────────
await createCollection(
'transactions',
'Transactions',
[
{ type: 'string', key: 'bucket_id', required: true, size: 64 },
{ type: 'string', key: 'type', required: true, size: 20 },
{ type: 'float', key: 'amount', required: true },
{ type: 'string', key: 'date', required: true, size: 24 },
{ type: 'string', key: 'notes', required: false, size: 512, default: '' },
{ type: 'float', key: 'balance_after', required: true },
{ type: 'string', key: 'user_id', required: true, size: 64 },
],
[
{ key: 'user_bucket_date', attributes: ['user_id', 'bucket_id', 'date'], orders: ['ASC', 'ASC', 'DESC'] },
],
);
console.log('\n\n✅ Setup complete!');
console.log('\nNext steps:');
console.log(' 1. npm run build');
console.log(' 2. Deploy the dist/ folder to your web server');
console.log(' 3. Open the app in Chrome on Android → "Add to Home Screen"');
}
main().catch((err) => {
console.error('\n❌ Setup failed:', err.message ?? err);
process.exit(1);
});