- 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)
246 lines
9.5 KiB
JavaScript
246 lines
9.5 KiB
JavaScript
#!/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);
|
||
});
|