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:
169
apps/appwrite/appwrite.json
Normal file
169
apps/appwrite/appwrite.json
Normal file
@@ -0,0 +1,169 @@
|
||||
{
|
||||
"$schema": "https://appwrite.io/sdk/schemas/appwrite.json",
|
||||
"projectId": "",
|
||||
"projectName": "BudgetWise",
|
||||
"databases": [
|
||||
{
|
||||
"databaseId": "budget_db",
|
||||
"name": "BudgetWise",
|
||||
"enabled": true,
|
||||
"collections": [
|
||||
{
|
||||
"databaseId": "budget_db",
|
||||
"collectionId": "balance_sheets",
|
||||
"name": "Balance Sheets",
|
||||
"enabled": true,
|
||||
"documentSecurity": false,
|
||||
"permissions": [
|
||||
"read(\"users\")",
|
||||
"create(\"users\")",
|
||||
"update(\"users\")",
|
||||
"delete(\"users\")"
|
||||
],
|
||||
"attributes": [
|
||||
{ "key": "month", "type": "integer", "required": true, "array": false },
|
||||
{ "key": "year", "type": "integer", "required": true, "array": false },
|
||||
{ "key": "buffer_type", "type": "string", "size": 20, "required": true, "array": false },
|
||||
{ "key": "buffer_value", "type": "float", "required": true, "default": 0, "array": false },
|
||||
{ "key": "user_id", "type": "string", "size": 64, "required": true, "array": false }
|
||||
],
|
||||
"indexes": [
|
||||
{
|
||||
"key": "user_month_year",
|
||||
"type": "key",
|
||||
"attributes": ["user_id", "month", "year"],
|
||||
"orders": ["ASC", "DESC", "DESC"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"databaseId": "budget_db",
|
||||
"collectionId": "incomes",
|
||||
"name": "Incomes",
|
||||
"enabled": true,
|
||||
"documentSecurity": false,
|
||||
"permissions": [
|
||||
"read(\"users\")",
|
||||
"create(\"users\")",
|
||||
"update(\"users\")",
|
||||
"delete(\"users\")"
|
||||
],
|
||||
"attributes": [
|
||||
{ "key": "balance_sheet_id", "type": "string", "size": 64, "required": true, "array": false },
|
||||
{ "key": "name", "type": "string", "size": 128,"required": true, "array": false },
|
||||
{ "key": "amount", "type": "float", "required": true, "array": false },
|
||||
{ "key": "frequency", "type": "string", "size": 20, "required": true, "array": false },
|
||||
{ "key": "user_id", "type": "string", "size": 64, "required": true, "array": false }
|
||||
],
|
||||
"indexes": [
|
||||
{
|
||||
"key": "user_sheet",
|
||||
"type": "key",
|
||||
"attributes": ["user_id", "balance_sheet_id"],
|
||||
"orders": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"databaseId": "budget_db",
|
||||
"collectionId": "buckets",
|
||||
"name": "Buckets",
|
||||
"enabled": true,
|
||||
"documentSecurity": false,
|
||||
"permissions": [
|
||||
"read(\"users\")",
|
||||
"create(\"users\")",
|
||||
"update(\"users\")",
|
||||
"delete(\"users\")"
|
||||
],
|
||||
"attributes": [
|
||||
{ "key": "name", "type": "string", "size": 128, "required": true, "array": false },
|
||||
{ "key": "description", "type": "string", "size": 512, "required": false, "default": "", "array": false },
|
||||
{ "key": "type", "type": "string", "size": 32, "required": true, "array": false },
|
||||
{ "key": "current_balance", "type": "float", "required": true, "default": 0, "array": false },
|
||||
{ "key": "goal_amount", "type": "float", "required": false, "default": 0, "array": false },
|
||||
{ "key": "goal_type", "type": "string", "size": 20, "required": false, "default": "amount", "array": false },
|
||||
{ "key": "goal_frequency", "type": "string", "size": 20, "required": false, "default": "monthly", "array": false },
|
||||
{ "key": "return_percent", "type": "float", "required": false, "default": 0, "array": false },
|
||||
{ "key": "return_frequency", "type": "string", "size": 20, "required": false, "default": "yearly", "array": false },
|
||||
{ "key": "color", "type": "string", "size": 16, "required": true, "array": false },
|
||||
{ "key": "sort_order", "type": "integer", "required": false, "default": 0, "array": false },
|
||||
{ "key": "user_id", "type": "string", "size": 64, "required": true, "array": false }
|
||||
],
|
||||
"indexes": [
|
||||
{
|
||||
"key": "user_order",
|
||||
"type": "key",
|
||||
"attributes": ["user_id", "sort_order"],
|
||||
"orders": ["ASC", "ASC"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"databaseId": "budget_db",
|
||||
"collectionId": "debts",
|
||||
"name": "Debts",
|
||||
"enabled": true,
|
||||
"documentSecurity": false,
|
||||
"permissions": [
|
||||
"read(\"users\")",
|
||||
"create(\"users\")",
|
||||
"update(\"users\")",
|
||||
"delete(\"users\")"
|
||||
],
|
||||
"attributes": [
|
||||
{ "key": "name", "type": "string", "size": 128, "required": true, "array": false },
|
||||
{ "key": "principal", "type": "float", "required": true, "array": false },
|
||||
{ "key": "remaining_balance", "type": "float", "required": true, "array": false },
|
||||
{ "key": "interest_rate", "type": "float", "required": true, "array": false },
|
||||
{ "key": "interest_frequency", "type": "string", "size": 20, "required": true, "array": false },
|
||||
{ "key": "term_months", "type": "integer", "required": true, "array": false },
|
||||
{ "key": "monthly_payment", "type": "float", "required": true, "array": false },
|
||||
{ "key": "is_auto_calculated", "type": "boolean", "required": true, "default": false, "array": false },
|
||||
{ "key": "start_date", "type": "string", "size": 24, "required": true, "array": false },
|
||||
{ "key": "user_id", "type": "string", "size": 64, "required": true, "array": false }
|
||||
],
|
||||
"indexes": [
|
||||
{
|
||||
"key": "user_id_idx",
|
||||
"type": "key",
|
||||
"attributes": ["user_id"],
|
||||
"orders": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"databaseId": "budget_db",
|
||||
"collectionId": "transactions",
|
||||
"name": "Transactions",
|
||||
"enabled": true,
|
||||
"documentSecurity": false,
|
||||
"permissions": [
|
||||
"read(\"users\")",
|
||||
"create(\"users\")",
|
||||
"update(\"users\")",
|
||||
"delete(\"users\")"
|
||||
],
|
||||
"attributes": [
|
||||
{ "key": "bucket_id", "type": "string", "size": 64, "required": true, "array": false },
|
||||
{ "key": "type", "type": "string", "size": 20, "required": true, "array": false },
|
||||
{ "key": "amount", "type": "float", "required": true, "array": false },
|
||||
{ "key": "date", "type": "string", "size": 24, "required": true, "array": false },
|
||||
{ "key": "notes", "type": "string", "size": 512, "required": false, "default": "", "array": false },
|
||||
{ "key": "balance_after","type": "float", "required": true, "array": false },
|
||||
{ "key": "user_id", "type": "string", "size": 64, "required": true, "array": false }
|
||||
],
|
||||
"indexes": [
|
||||
{
|
||||
"key": "user_bucket_date",
|
||||
"type": "key",
|
||||
"attributes": ["user_id", "bucket_id", "date"],
|
||||
"orders": ["ASC", "ASC", "DESC"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"functions": []
|
||||
}
|
||||
0
apps/appwrite/functions/.gitkeep
Normal file
0
apps/appwrite/functions/.gitkeep
Normal file
12
apps/appwrite/package.json
Normal file
12
apps/appwrite/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@budgetwise/appwrite",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"deploy": "bash scripts/deploy.sh",
|
||||
"setup": "node scripts/setup-appwrite.cjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"node-appwrite": "^12.0.0"
|
||||
}
|
||||
}
|
||||
58
apps/appwrite/scripts/deploy.sh
Executable file
58
apps/appwrite/scripts/deploy.sh
Executable 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."
|
||||
245
apps/appwrite/scripts/setup-appwrite.cjs
Normal file
245
apps/appwrite/scripts/setup-appwrite.cjs
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user