diff --git a/.env b/.env new file mode 100644 index 00000000..2dc9251f --- /dev/null +++ b/.env @@ -0,0 +1 @@ +DATABASE_URL=sqlite://db.sqlite \ No newline at end of file diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 4a4a632e..79e8260a 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -2,11 +2,12 @@ name: Deploy on: push: branches: [main] + workflow_dispatch: {} jobs: deploy: name: Deploy - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 permissions: contents: read steps: @@ -16,14 +17,55 @@ jobs: targets: aarch64-unknown-linux-gnu - uses: Swatinem/rust-cache@v2 - name: install gcc-aarch64-linux-gnu - run: sudo apt install -y gcc-aarch64-linux-gnu - - uses: webfactory/ssh-agent@v0.9.0 + run: sudo apt update && sudo apt install -y gcc-aarch64-linux-gnu + + - uses: actions/setup-node@v4 with: - ssh-private-key: ${{ secrets.SSH_KEY }} - - name: ssh-keyscan + node-version: 23 + - run: npm ci + - run: npm run build:styles.min + + - name: Setup SSH key run: | - mkdir -p ~/.ssh - ssh-keyscan wlsd.lightandsound.design > ~/.ssh/known_hosts + mkdir ~/.ssh + chmod 700 ~/.ssh + echo "${{ secrets.ROOT_SSH_PRIVKEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan beta.lightandsound.design > ~/.ssh/known_hosts chmod 600 ~/.ssh/known_hosts - - name: deploy - run: scripts/deploy.sh ec2-user@wlsd.lightandsound.design + + - name: Generate config + run: | + envsubst < config/prod.toml > config/prod.toml.subst + mv config/prod.toml.subst config/prod.toml + env: + SMTP_USERNAME: ${{ secrets.SMTP_USERNAME }} + SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }} + STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} + STRIPE_PUBLISHABLE_KEY: ${{ secrets.STRIPE_PUBLISHABLE_KEY }} + STRIPE_WEBHOOK_KEY: ${{ secrets.STRIPE_WEBHOOK_KEY }} + EMAIL_FROM: ${{ secrets.EMAIL_FROM }} + EMAIL_CONTACT_TO: ${{ secrets.EMAIL_CONTACT_TO }} + EMAIL_NEWSLETTER_REPLY_TO: ${{ secrets.EMAIL_NEWSLETTER_REPLY_TO }} + CF_TURNSTILE_SITE_KEY: ${{ secrets.CF_TURNSTILE_SITE_KEY }} + CF_TURNSTILE_SECRET_KEY: ${{ secrets.CF_TURNSTILE_SECRET_KEY }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + + - name: Generate content hashes + run: | + set -euo pipefail + find frontend/templates -type f -exec perl -i -pe \ + 's|(/[^?"]+)\?version=CONTENT_HASH|my $hash=`md5sum ./frontend/$1 \| cut -d" " -f1`; chomp($hash); "$1?version=$hash"|ge' {} \; + + - name: Cross compile + run: cargo build --release --target aarch64-unknown-linux-gnu + env: + SQLX_OFFLINE: true + + - name: Deploy + id: deploy + run: scripts/deploy.sh ec2-user@beta.lightandsound.design + + - name: Rollback on failure + if: ${{ failure() && steps.deploy.conclusion == 'failure' }} + run: scripts/rollback.sh ec2-user@beta.lightandsound.design diff --git a/.github/workflows/rollback.yaml b/.github/workflows/rollback.yaml new file mode 100644 index 00000000..da58f428 --- /dev/null +++ b/.github/workflows/rollback.yaml @@ -0,0 +1,21 @@ +name: Rollback +on: + workflow_dispatch: {} + +jobs: + rollback: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - name: Setup SSH key + run: | + mkdir -p ~/.ssh + chmod 700 ~/.ssh + echo "${{ secrets.ROOT_SSH_PRIVKEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan beta.lightandsound.design > ~/.ssh/known_hosts + chmod 600 ~/.ssh/known_hosts + + - name: Rollback + run: scripts/rollback.sh ec2-user@beta.lightandsound.design diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index e510b0d1..1afe622f 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,15 +1,15 @@ name: Test on: pull_request: - push: - branches: ["*"] jobs: test: name: Test - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 permissions: contents: read + env: + SQLX_OFFLINE: true steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -18,30 +18,51 @@ jobs: rustfmt: name: Format - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 permissions: contents: read + env: + SQLX_OFFLINE: true steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt - run: cargo fmt --check + prettier: + name: Format frontend + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 23 + - run: npm ci + - run: npm run format:check + clippy: name: Lint - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 permissions: contents: read + env: + SQLX_OFFLINE: true steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable + with: + components: clippy - uses: Swatinem/rust-cache@v2 - run: cargo clippy -- -D warnings cross: name: Cross-compile - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 permissions: contents: read + env: + SQLX_OFFLINE: true steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -49,5 +70,5 @@ jobs: targets: aarch64-unknown-linux-gnu - uses: Swatinem/rust-cache@v2 - name: install gcc-aarch64-linux-gnu - run: sudo apt install -y gcc-aarch64-linux-gnu + run: sudo apt update && sudo apt install -y gcc-aarch64-linux-gnu - run: cargo build --target aarch64-unknown-linux-gnu diff --git a/.gitignore b/.gitignore index 579ab3ae..665bfe2b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,16 @@ -/target +config/* +!config/prod.toml +!config/dev.toml +!config/seed_data.sql +frontend/static/main.css + Cargo.lock +target/ *.sqlite *.sqlite-* +.DS_Store +deno.lock +node_modules/ +venv/ +worktree/ +.env.local diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..2a51f3e4 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +frontend/templates/emails/ diff --git a/.sqlx/query-0079d311c158604a615f7d20696a118a027496b922994398814e35fb827f145b.json b/.sqlx/query-0079d311c158604a615f7d20696a118a027496b922994398814e35fb827f145b.json new file mode 100644 index 00000000..0a79824b --- /dev/null +++ b/.sqlx/query-0079d311c158604a615f7d20696a118a027496b922994398814e35fb827f145b.json @@ -0,0 +1,92 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT e.*, u.email as address FROM emails e\n JOIN users u ON u.id = e.user_id\n WHERE e.post_id = ? AND e.list_id = ?\n AND ifnull(e.sent_at, '') = (\n SELECT ifnull(MAX(ee.sent_at), '')\n FROM emails ee\n WHERE ee.user_id = e.user_id\n AND ee.post_id = e.post_id\n AND ee.list_id = e.list_id\n );\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "kind", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "user_version", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "post_id", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "list_id", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "event_id", + "ordinal": 6, + "type_info": "Integer" + }, + { + "name": "notification_id", + "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "error", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 9, + "type_info": "Datetime" + }, + { + "name": "sent_at", + "ordinal": 10, + "type_info": "Datetime" + }, + { + "name": "opened_at", + "ordinal": 11, + "type_info": "Datetime" + }, + { + "name": "address", + "ordinal": 12, + "type_info": "Text" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + true, + false, + true, + true, + false + ] + }, + "hash": "0079d311c158604a615f7d20696a118a027496b922994398814e35fb827f145b" +} diff --git a/.sqlx/query-03515ca1fa370ad60cc1ad4c364b255892f01f610ff6c78dcfbb110881b514bd.json b/.sqlx/query-03515ca1fa370ad60cc1ad4c364b255892f01f610ff6c78dcfbb110881b514bd.json new file mode 100644 index 00000000..69605b25 --- /dev/null +++ b/.sqlx/query-03515ca1fa370ad60cc1ad4c364b255892f01f610ff6c78dcfbb110881b514bd.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "SELECT id, user_id, flyer_name, x, y, rotation, link_url, image_version\n FROM flyers ORDER BY id DESC LIMIT ? OFFSET ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "user_id", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "flyer_name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "x", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "y", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "rotation", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "link_url", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "image_version", + "ordinal": 7, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + false + ] + }, + "hash": "03515ca1fa370ad60cc1ad4c364b255892f01f610ff6c78dcfbb110881b514bd" +} diff --git a/.sqlx/query-0398303d808463f82501c643f30d4089030514220d305c38a3d3fa6131d6a5e4.json b/.sqlx/query-0398303d808463f82501c643f30d4089030514220d305c38a3d3fa6131d6a5e4.json new file mode 100644 index 00000000..6d4d2450 --- /dev/null +++ b/.sqlx/query-0398303d808463f82501c643f30d4089030514220d305c38a3d3fa6131d6a5e4.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "UPDATE rsvps SET checkin_at = CURRENT_TIMESTAMP\n WHERE id = (\n SELECT r.id FROM rsvps r\n JOIN rsvp_sessions rs ON rs.id = r.session_id\n WHERE rs.event_id = ? AND r.user_id = ?\n AND rs.status IN ('payment_pending', 'payment_confirmed')\n )\n RETURNING checkin_at AS 'checkin_at!'", + "describe": { + "columns": [ + { + "name": "checkin_at!", + "ordinal": 0, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + true + ] + }, + "hash": "0398303d808463f82501c643f30d4089030514220d305c38a3d3fa6131d6a5e4" +} diff --git a/.sqlx/query-04db7f4b8ca820ee6c49f846ed24988dbe0dc0626f2c4a448d5e6119aea37b72.json b/.sqlx/query-04db7f4b8ca820ee6c49f846ed24988dbe0dc0626f2c4a448d5e6119aea37b72.json new file mode 100644 index 00000000..21de6dd7 --- /dev/null +++ b/.sqlx/query-04db7f4b8ca820ee6c49f846ed24988dbe0dc0626f2c4a448d5e6119aea37b72.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM login_tokens WHERE user_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "04db7f4b8ca820ee6c49f846ed24988dbe0dc0626f2c4a448d5e6119aea37b72" +} diff --git a/.sqlx/query-058c90033d34c0db0877b72a584b4dcf50d26f7beb511026e79642a42e73bb11.json b/.sqlx/query-058c90033d34c0db0877b72a584b4dcf50d26f7beb511026e79642a42e73bb11.json new file mode 100644 index 00000000..5d15bec5 --- /dev/null +++ b/.sqlx/query-058c90033d34c0db0877b72a584b4dcf50d26f7beb511026e79642a42e73bb11.json @@ -0,0 +1,92 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO emails (kind, user_id, user_version, event_id)\n SELECT ?, u.id, uh.version, ?\n FROM rsvps r\n JOIN rsvp_sessions rs ON rs.id = r.session_id\n JOIN users u ON u.id = r.user_id\n JOIN user_history uh ON uh.user_id = u.id\n WHERE rs.event_id = ?\n AND NOT EXISTS (\n SELECT 1\n FROM emails ee\n WHERE ee.kind = ?\n AND ee.user_id = u.id\n AND ee.event_id = ?\n )\n RETURNING *, (\n SELECT u.email FROM users u\n WHERE u.id = emails.user_id\n ) AS \"address!\"\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "kind", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "user_version", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "post_id", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "list_id", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "event_id", + "ordinal": 6, + "type_info": "Integer" + }, + { + "name": "notification_id", + "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "error", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 9, + "type_info": "Datetime" + }, + { + "name": "sent_at", + "ordinal": 10, + "type_info": "Datetime" + }, + { + "name": "opened_at", + "ordinal": 11, + "type_info": "Datetime" + }, + { + "name": "address!", + "ordinal": 12, + "type_info": "Text" + } + ], + "parameters": { + "Right": 5 + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + true, + false, + true, + true, + true + ] + }, + "hash": "058c90033d34c0db0877b72a584b4dcf50d26f7beb511026e79642a42e73bb11" +} diff --git a/.sqlx/query-0bc3c1284fb9e785ba4e30b7722a40e6ef4be76ba36ff02133c90cf54851dcc1.json b/.sqlx/query-0bc3c1284fb9e785ba4e30b7722a40e6ef4be76ba36ff02133c90cf54851dcc1.json new file mode 100644 index 00000000..31a32e23 --- /dev/null +++ b/.sqlx/query-0bc3c1284fb9e785ba4e30b7722a40e6ef4be76ba36ff02133c90cf54851dcc1.json @@ -0,0 +1,92 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO emails (kind, user_id, user_version, event_id, list_id)\n SELECT ?, u.id, uh.version, ?, ?\n FROM list_members lm\n JOIN users u ON u.id = lm.user_id\n JOIN user_history uh ON uh.user_id = u.id\n WHERE lm.list_id = ?\n AND NOT EXISTS (\n SELECT 1\n FROM emails ee\n WHERE ee.kind = ?\n AND ee.user_id = u.id\n AND ee.event_id = ?\n AND ee.list_id = ?\n )\n RETURNING *, (\n SELECT u.email FROM users u\n WHERE u.id = emails.user_id\n ) AS address\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "kind", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "user_version", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "post_id", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "list_id", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "event_id", + "ordinal": 6, + "type_info": "Integer" + }, + { + "name": "notification_id", + "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "error", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 9, + "type_info": "Datetime" + }, + { + "name": "sent_at", + "ordinal": 10, + "type_info": "Datetime" + }, + { + "name": "opened_at", + "ordinal": 11, + "type_info": "Datetime" + }, + { + "name": "address", + "ordinal": 12, + "type_info": "Text" + } + ], + "parameters": { + "Right": 7 + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + true, + false, + true, + true, + false + ] + }, + "hash": "0bc3c1284fb9e785ba4e30b7722a40e6ef4be76ba36ff02133c90cf54851dcc1" +} diff --git a/.sqlx/query-104bd1cde37de51eda2cc2efc7be8f1fa77b7679b342a503905471a7b9ad78d1.json b/.sqlx/query-104bd1cde37de51eda2cc2efc7be8f1fa77b7679b342a503905471a7b9ad78d1.json new file mode 100644 index 00000000..1287a3d9 --- /dev/null +++ b/.sqlx/query-104bd1cde37de51eda2cc2efc7be8f1fa77b7679b342a503905471a7b9ad78d1.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "SELECT\n e.id, e.title, e.slug, e.start, e.guest_list_id, e.capacity,\n COALESCE(\n (SELECT COUNT(*)\n FROM rsvps r\n JOIN rsvp_sessions rs ON rs.id = r.session_id\n WHERE rs.event_id = e.id\n AND rs.status IN ('payment_pending', 'payment_confirmed')),\n 0\n ) as \"rsvp_count!: i64\",\n COALESCE(\n (SELECT SUM(r.contribution)\n FROM rsvps r\n JOIN rsvp_sessions rs ON rs.id = r.session_id\n WHERE rs.event_id = e.id\n AND rs.status IN ('payment_pending', 'payment_confirmed')),\n 0\n ) as \"total_contributions!: i64\"\n FROM events e", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "title", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "slug", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "start", + "ordinal": 3, + "type_info": "Datetime" + }, + { + "name": "guest_list_id", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "capacity", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "rsvp_count!: i64", + "ordinal": 6, + "type_info": "Integer" + }, + { + "name": "total_contributions!: i64", + "ordinal": 7, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + false, + false + ] + }, + "hash": "104bd1cde37de51eda2cc2efc7be8f1fa77b7679b342a503905471a7b9ad78d1" +} diff --git a/.sqlx/query-10c6797083cfbca6d41650df6cd59db57bcb59f0f8ed25f954a98fb383f9b004.json b/.sqlx/query-10c6797083cfbca6d41650df6cd59db57bcb59f0f8ed25f954a98fb383f9b004.json new file mode 100644 index 00000000..1d901e71 --- /dev/null +++ b/.sqlx/query-10c6797083cfbca6d41650df6cd59db57bcb59f0f8ed25f954a98fb383f9b004.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "UPDATE manual_rsvps SET checkin_at = CURRENT_TIMESTAMP WHERE event_id = ? AND user_id = ? RETURNING checkin_at AS 'checkin_at!'", + "describe": { + "columns": [ + { + "name": "checkin_at!", + "ordinal": 0, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + true + ] + }, + "hash": "10c6797083cfbca6d41650df6cd59db57bcb59f0f8ed25f954a98fb383f9b004" +} diff --git a/.sqlx/query-177c4b9cc7901a3b906e5969b86b1c11e6acbfb8e86e98f197d7333030b17964.json b/.sqlx/query-177c4b9cc7901a3b906e5969b86b1c11e6acbfb8e86e98f197d7333030b17964.json new file mode 100644 index 00000000..11a18a57 --- /dev/null +++ b/.sqlx/query-177c4b9cc7901a3b906e5969b86b1c11e6acbfb8e86e98f197d7333030b17964.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM notifications WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "177c4b9cc7901a3b906e5969b86b1c11e6acbfb8e86e98f197d7333030b17964" +} diff --git a/.sqlx/query-180ab567d16a1ba49a80f5116c3f1556e73aa0db8917dc0de1c1e1e40ba204be.json b/.sqlx/query-180ab567d16a1ba49a80f5116c3f1556e73aa0db8917dc0de1c1e1e40ba204be.json new file mode 100644 index 00000000..0d5f67f2 --- /dev/null +++ b/.sqlx/query-180ab567d16a1ba49a80f5116c3f1556e73aa0db8917dc0de1c1e1e40ba204be.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "SELECT width, height, image_thumb, strftime('%s', updated_at) as \"version!: i64\"\n FROM event_flyers WHERE event_id = ?", + "describe": { + "columns": [ + { + "name": "width", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "height", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "image_thumb", + "ordinal": 2, + "type_info": "Blob" + }, + { + "name": "version!: i64", + "ordinal": 3, + "type_info": "Null" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + null + ] + }, + "hash": "180ab567d16a1ba49a80f5116c3f1556e73aa0db8917dc0de1c1e1e40ba204be" +} diff --git a/.sqlx/query-1edafe5420cc73624ca34ab82117236d583414bbd76bf14dbc9b679b491455ae.json b/.sqlx/query-1edafe5420cc73624ca34ab82117236d583414bbd76bf14dbc9b679b491455ae.json new file mode 100644 index 00000000..45277398 --- /dev/null +++ b/.sqlx/query-1edafe5420cc73624ca34ab82117236d583414bbd76bf14dbc9b679b491455ae.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "SELECT\n r.spot_id,\n SUM(CASE WHEN rs.status IN ('payment_pending', 'payment_confirmed') THEN 1 ELSE 0 END) as \"rsvp_count!: i64\",\n SUM(CASE WHEN rs.status IN ('selection', 'attendees', 'contribution') THEN 1 ELSE 0 END) as \"cart_count!: i64\"\n FROM rsvps r\n JOIN rsvp_sessions rs ON rs.id = r.session_id\n WHERE rs.event_id = ?\n GROUP BY r.spot_id", + "describe": { + "columns": [ + { + "name": "spot_id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "rsvp_count!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "cart_count!: i64", + "ordinal": 2, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "1edafe5420cc73624ca34ab82117236d583414bbd76bf14dbc9b679b491455ae" +} diff --git a/.sqlx/query-1fc3b0f3a8cf03207671dff5781bd34c8f4e66f8eb981fe545a23e76a9366740.json b/.sqlx/query-1fc3b0f3a8cf03207671dff5781bd34c8f4e66f8eb981fe545a23e76a9366740.json new file mode 100644 index 00000000..8173ce76 --- /dev/null +++ b/.sqlx/query-1fc3b0f3a8cf03207671dff5781bd34c8f4e66f8eb981fe545a23e76a9366740.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE flyers\n SET x = ?, y = ?, rotation = ?\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "1fc3b0f3a8cf03207671dff5781bd34c8f4e66f8eb981fe545a23e76a9366740" +} diff --git a/.sqlx/query-2109a7aef649f930c002ed0a712cad9574c31d153ab5f45c5b91d15c21c1c729.json b/.sqlx/query-2109a7aef649f930c002ed0a712cad9574c31d153ab5f45c5b91d15c21c1c729.json new file mode 100644 index 00000000..84ff5d19 --- /dev/null +++ b/.sqlx/query-2109a7aef649f930c002ed0a712cad9574c31d153ab5f45c5b91d15c21c1c729.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE rsvp_sessions\n SET stripe_client_secret = NULL, updated_at = CURRENT_TIMESTAMP\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "2109a7aef649f930c002ed0a712cad9574c31d153ab5f45c5b91d15c21c1c729" +} diff --git a/.sqlx/query-2199d77ace9dfa4cd493fe218b45d3b833291a4e671d0205317496af255093d1.json b/.sqlx/query-2199d77ace9dfa4cd493fe218b45d3b833291a4e671d0205317496af255093d1.json new file mode 100644 index 00000000..933813d7 --- /dev/null +++ b/.sqlx/query-2199d77ace9dfa4cd493fe218b45d3b833291a4e671d0205317496af255093d1.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM notifications", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "content", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 3, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 4, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "2199d77ace9dfa4cd493fe218b45d3b833291a4e671d0205317496af255093d1" +} diff --git a/.sqlx/query-2746bd6c54e711197c4e9f1d35abbb37d08c3840fb8b399dba03b4430f5e841a.json b/.sqlx/query-2746bd6c54e711197c4e9f1d35abbb37d08c3840fb8b399dba03b4430f5e841a.json new file mode 100644 index 00000000..938ac95c --- /dev/null +++ b/.sqlx/query-2746bd6c54e711197c4e9f1d35abbb37d08c3840fb8b399dba03b4430f5e841a.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO user_history (user_id, version, email, first_name, last_name, phone, created_at)\n VALUES (?, 0, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "2746bd6c54e711197c4e9f1d35abbb37d08c3840fb8b399dba03b4430f5e841a" +} diff --git a/.sqlx/query-2793e732a7931e6de799741621cc1df51839feefb24b71876e70a14fcd668de5.json b/.sqlx/query-2793e732a7931e6de799741621cc1df51839feefb24b71876e70a14fcd668de5.json new file mode 100644 index 00000000..e1217695 --- /dev/null +++ b/.sqlx/query-2793e732a7931e6de799741621cc1df51839feefb24b71876e70a14fcd668de5.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT OR IGNORE INTO list_members (list_id, user_id) VALUES (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "2793e732a7931e6de799741621cc1df51839feefb24b71876e70a14fcd668de5" +} diff --git a/.sqlx/query-2b2aba96a756b2595196a2d38d5c56fdfbb72b1f4c46f26c96e922f5b0f1ca59.json b/.sqlx/query-2b2aba96a756b2595196a2d38d5c56fdfbb72b1f4c46f26c96e922f5b0f1ca59.json new file mode 100644 index 00000000..09f061b1 --- /dev/null +++ b/.sqlx/query-2b2aba96a756b2595196a2d38d5c56fdfbb72b1f4c46f26c96e922f5b0f1ca59.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM rsvp_sessions WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "2b2aba96a756b2595196a2d38d5c56fdfbb72b1f4c46f26c96e922f5b0f1ca59" +} diff --git a/.sqlx/query-2ccab2a439e71d18ea785ca0f378fe403d86361031a78f66c272267d6b24374e.json b/.sqlx/query-2ccab2a439e71d18ea785ca0f378fe403d86361031a78f66c272267d6b24374e.json new file mode 100644 index 00000000..5499d325 --- /dev/null +++ b/.sqlx/query-2ccab2a439e71d18ea785ca0f378fe403d86361031a78f66c272267d6b24374e.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO flyers (user_id, x, y, image_data, link_url, flyer_name)\n VALUES (?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "2ccab2a439e71d18ea785ca0f378fe403d86361031a78f66c272267d6b24374e" +} diff --git a/.sqlx/query-2ed0030ecb0e793bb11ef58725ccbfc60a2702ad427d12decf47567e2b24b859.json b/.sqlx/query-2ed0030ecb0e793bb11ef58725ccbfc60a2702ad427d12decf47567e2b24b859.json new file mode 100644 index 00000000..c0dbf4e1 --- /dev/null +++ b/.sqlx/query-2ed0030ecb0e793bb11ef58725ccbfc60a2702ad427d12decf47567e2b24b859.json @@ -0,0 +1,92 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO emails (kind, user_id, user_version, event_id)\n SELECT ?, u.id, uh.version, ?\n FROM users u\n JOIN user_history uh ON uh.user_id = u.id\n WHERE u.id = ?\n RETURNING *, (\n SELECT u.email FROM users u\n WHERE u.id = emails.user_id\n ) AS \"address!\"\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "kind", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "user_version", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "post_id", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "list_id", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "event_id", + "ordinal": 6, + "type_info": "Integer" + }, + { + "name": "notification_id", + "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "error", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 9, + "type_info": "Datetime" + }, + { + "name": "sent_at", + "ordinal": 10, + "type_info": "Datetime" + }, + { + "name": "opened_at", + "ordinal": 11, + "type_info": "Datetime" + }, + { + "name": "address!", + "ordinal": 12, + "type_info": "Text" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + true, + false, + true, + true, + true + ] + }, + "hash": "2ed0030ecb0e793bb11ef58725ccbfc60a2702ad427d12decf47567e2b24b859" +} diff --git a/.sqlx/query-2ee5f2eb3e517e0954c5bc4d7161c1b0e36e560b22aea8eed6209df746e7c1d0.json b/.sqlx/query-2ee5f2eb3e517e0954c5bc4d7161c1b0e36e560b22aea8eed6209df746e7c1d0.json new file mode 100644 index 00000000..b074aed1 --- /dev/null +++ b/.sqlx/query-2ee5f2eb3e517e0954c5bc4d7161c1b0e36e560b22aea8eed6209df746e7c1d0.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT rs.status, u.email\n FROM users u\n JOIN rsvps r ON r.user_id = u.id\n JOIN rsvp_sessions rs ON rs.id = r.session_id\n WHERE rs.event_id = ?\n AND rs.id != ?\n ", + "describe": { + "columns": [ + { + "name": "status", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "email", + "ordinal": 1, + "type_info": "Text" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false + ] + }, + "hash": "2ee5f2eb3e517e0954c5bc4d7161c1b0e36e560b22aea8eed6209df746e7c1d0" +} diff --git a/.sqlx/query-2f46697bbde8a99eb6862750dca0073f399329d7ef7f28ccabf8fbbd61e0f370.json b/.sqlx/query-2f46697bbde8a99eb6862750dca0073f399329d7ef7f28ccabf8fbbd61e0f370.json new file mode 100644 index 00000000..ae8a6263 --- /dev/null +++ b/.sqlx/query-2f46697bbde8a99eb6862750dca0073f399329d7ef7f28ccabf8fbbd61e0f370.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM rsvp_sessions WHERE event_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "2f46697bbde8a99eb6862750dca0073f399329d7ef7f28ccabf8fbbd61e0f370" +} diff --git a/.sqlx/query-2ffdd1333f99f55acf7527a60d1b83e1fd9b41c3c704a92526be542dba672c77.json b/.sqlx/query-2ffdd1333f99f55acf7527a60d1b83e1fd9b41c3c704a92526be542dba672c77.json new file mode 100644 index 00000000..59c2576b --- /dev/null +++ b/.sqlx/query-2ffdd1333f99f55acf7527a60d1b83e1fd9b41c3c704a92526be542dba672c77.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM rsvps WHERE session_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "2ffdd1333f99f55acf7527a60d1b83e1fd9b41c3c704a92526be542dba672c77" +} diff --git a/.sqlx/query-3082723b6deb04e91870f0b33cbd3046cfb4d50bc1e4e8f28d80c322e2425d84.json b/.sqlx/query-3082723b6deb04e91870f0b33cbd3046cfb4d50bc1e4e8f28d80c322e2425d84.json new file mode 100644 index 00000000..e772bbae --- /dev/null +++ b/.sqlx/query-3082723b6deb04e91870f0b33cbd3046cfb4d50bc1e4e8f28d80c322e2425d84.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM rsvp_sessions\n WHERE user_id IN (\n SELECT u.id\n FROM users u\n WHERE u.email = ? COLLATE NOCASE\n )\n AND id != ?\n AND event_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "3082723b6deb04e91870f0b33cbd3046cfb4d50bc1e4e8f28d80c322e2425d84" +} diff --git a/.sqlx/query-321b27bd2a740366e8feb9022ace8931b2f3913cef7ab6524c637fabb7902620.json b/.sqlx/query-321b27bd2a740366e8feb9022ace8931b2f3913cef7ab6524c637fabb7902620.json new file mode 100644 index 00000000..8b3b13cf --- /dev/null +++ b/.sqlx/query-321b27bd2a740366e8feb9022ace8931b2f3913cef7ab6524c637fabb7902620.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE events SET dayof_sent_at = CURRENT_TIMESTAMP WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "321b27bd2a740366e8feb9022ace8931b2f3913cef7ab6524c637fabb7902620" +} diff --git a/.sqlx/query-32785031f8a51434947c298dbc24399b153cfa6b33b374aa15d0b259e40bf516.json b/.sqlx/query-32785031f8a51434947c298dbc24399b153cfa6b33b374aa15d0b259e40bf516.json new file mode 100644 index 00000000..30250828 --- /dev/null +++ b/.sqlx/query-32785031f8a51434947c298dbc24399b153cfa6b33b374aa15d0b259e40bf516.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO user_history (user_id, version, email, first_name, last_name, phone)\n VALUES (?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "32785031f8a51434947c298dbc24399b153cfa6b33b374aa15d0b259e40bf516" +} diff --git a/.sqlx/query-33440aa0461a78861e31f129f827e7c463498f214beaf0cd38b51d712e2e9cd7.json b/.sqlx/query-33440aa0461a78861e31f129f827e7c463498f214beaf0cd38b51d712e2e9cd7.json new file mode 100644 index 00000000..2321bb9a --- /dev/null +++ b/.sqlx/query-33440aa0461a78861e31f129f827e7c463498f214beaf0cd38b51d712e2e9cd7.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE users\n SET email = ?,\n first_name = ?,\n last_name = ?,\n phone = ?,\n updated_at = CURRENT_TIMESTAMP\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "33440aa0461a78861e31f129f827e7c463498f214beaf0cd38b51d712e2e9cd7" +} diff --git a/.sqlx/query-35802e346fe8128d39b31e50bb9c00e2653038ef6e04cc6f611eb468d344a188.json b/.sqlx/query-35802e346fe8128d39b31e50bb9c00e2653038ef6e04cc6f611eb468d344a188.json new file mode 100644 index 00000000..dfa1b23c --- /dev/null +++ b/.sqlx/query-35802e346fe8128d39b31e50bb9c00e2653038ef6e04cc6f611eb468d344a188.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM flyers WHERE id = ? AND user_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "35802e346fe8128d39b31e50bb9c00e2653038ef6e04cc6f611eb468d344a188" +} diff --git a/.sqlx/query-3879c7de54dce18d6493951fbc6e267e3cf2afb9f723f4d3ee917facff946386.json b/.sqlx/query-3879c7de54dce18d6493951fbc6e267e3cf2afb9f723f4d3ee917facff946386.json new file mode 100644 index 00000000..459085cf --- /dev/null +++ b/.sqlx/query-3879c7de54dce18d6493951fbc6e267e3cf2afb9f723f4d3ee917facff946386.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE rsvp_sessions\n SET status = ?,\n updated_at = CURRENT_TIMESTAMP\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "3879c7de54dce18d6493951fbc6e267e3cf2afb9f723f4d3ee917facff946386" +} diff --git a/.sqlx/query-387a642013c416d25229ffffe2ca1814a922f4fcf1f61cf5aea6c7c1e213c585.json b/.sqlx/query-387a642013c416d25229ffffe2ca1814a922f4fcf1f61cf5aea6c7c1e213c585.json new file mode 100644 index 00000000..a528b1c2 --- /dev/null +++ b/.sqlx/query-387a642013c416d25229ffffe2ca1814a922f4fcf1f61cf5aea6c7c1e213c585.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE flyers SET link_url = ?, flyer_name = ? WHERE id = ? AND user_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "387a642013c416d25229ffffe2ca1814a922f4fcf1f61cf5aea6c7c1e213c585" +} diff --git a/.sqlx/query-3cc70a5dcbf66851e4c64b95c64c7bc98821b95da14ea2dbfd2086190a3dece4.json b/.sqlx/query-3cc70a5dcbf66851e4c64b95c64c7bc98821b95da14ea2dbfd2086190a3dece4.json new file mode 100644 index 00000000..771f077c --- /dev/null +++ b/.sqlx/query-3cc70a5dcbf66851e4c64b95c64c7bc98821b95da14ea2dbfd2086190a3dece4.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT EXISTS(\n SELECT 1\n FROM list_members\n WHERE list_id = ? AND user_id = ?\n ) AS \"exists!: bool\"\n ", + "describe": { + "columns": [ + { + "name": "exists!: bool", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false + ] + }, + "hash": "3cc70a5dcbf66851e4c64b95c64c7bc98821b95da14ea2dbfd2086190a3dece4" +} diff --git a/.sqlx/query-3d32def4ae65927da56ccbd9870afe3c1c8dc8d6e1cf616eb34d5c869b6ccbd8.json b/.sqlx/query-3d32def4ae65927da56ccbd9870afe3c1c8dc8d6e1cf616eb34d5c869b6ccbd8.json new file mode 100644 index 00000000..2f2b1fd1 --- /dev/null +++ b/.sqlx/query-3d32def4ae65927da56ccbd9870afe3c1c8dc8d6e1cf616eb34d5c869b6ccbd8.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO event_flyers (event_id, width, height, image_full, image_lg, image_md, image_sm, image_thumb, updated_at)\n SELECT ?, width, height, image_full, image_lg, image_md, image_sm, image_thumb, CURRENT_TIMESTAMP\n FROM event_flyers WHERE event_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "3d32def4ae65927da56ccbd9870afe3c1c8dc8d6e1cf616eb34d5c869b6ccbd8" +} diff --git a/.sqlx/query-3d82179023e55e3899ad4cde3f700122bf54516baa25b9d7eebc5a970ec74145.json b/.sqlx/query-3d82179023e55e3899ad4cde3f700122bf54516baa25b9d7eebc5a970ec74145.json new file mode 100644 index 00000000..975537f3 --- /dev/null +++ b/.sqlx/query-3d82179023e55e3899ad4cde3f700122bf54516baa25b9d7eebc5a970ec74145.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "SELECT\n s.id,\n s.token,\n s.status,\n s.created_at,\n s.updated_at,\n e.title AS event_title,\n e.slug AS event_slug,\n u.email AS user_email\n FROM rsvp_sessions s\n JOIN events e ON e.id = s.event_id\n LEFT JOIN users u ON u.id = s.user_id\n WHERE e.start > datetime('now', '-24 hours')\n ORDER BY s.updated_at DESC", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "token", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "status", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 3, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 4, + "type_info": "Datetime" + }, + { + "name": "event_title", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "event_slug", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "user_email", + "ordinal": 7, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "3d82179023e55e3899ad4cde3f700122bf54516baa25b9d7eebc5a970ec74145" +} diff --git a/.sqlx/query-3ff50282363379c28198475231ca81b26e1596512e2d8a01f9c98e4da947323e.json b/.sqlx/query-3ff50282363379c28198475231ca81b26e1596512e2d8a01f9c98e4da947323e.json new file mode 100644 index 00000000..a6880561 --- /dev/null +++ b/.sqlx/query-3ff50282363379c28198475231ca81b26e1596512e2d8a01f9c98e4da947323e.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "SELECT id, user_id, flyer_name, x, y, rotation, link_url, image_version FROM flyers", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "user_id", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "flyer_name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "x", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "y", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "rotation", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "link_url", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "image_version", + "ordinal": 7, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + false + ] + }, + "hash": "3ff50282363379c28198475231ca81b26e1596512e2d8a01f9c98e4da947323e" +} diff --git a/.sqlx/query-40f493f5a0f47f170c97e2f60b972a5ea03360acc445bbc9889a30500802da8a.json b/.sqlx/query-40f493f5a0f47f170c97e2f60b972a5ea03360acc445bbc9889a30500802da8a.json new file mode 100644 index 00000000..08996f60 --- /dev/null +++ b/.sqlx/query-40f493f5a0f47f170c97e2f60b972a5ea03360acc445bbc9889a30500802da8a.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE emails SET sent_at = CURRENT_TIMESTAMP, error = ?\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "40f493f5a0f47f170c97e2f60b972a5ea03360acc445bbc9889a30500802da8a" +} diff --git a/.sqlx/query-42457954bba1629b88f4c8c526ff2722ef830399ef1c96fa089480aa3d252f2a.json b/.sqlx/query-42457954bba1629b88f4c8c526ff2722ef830399ef1c96fa089480aa3d252f2a.json new file mode 100644 index 00000000..6b378d31 --- /dev/null +++ b/.sqlx/query-42457954bba1629b88f4c8c526ff2722ef830399ef1c96fa089480aa3d252f2a.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE flyers\n SET x = ?, y = ?, rotation = ?\n WHERE id = ? AND user_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "42457954bba1629b88f4c8c526ff2722ef830399ef1c96fa089480aa3d252f2a" +} diff --git a/.sqlx/query-438b5a20e4b8369cb5cf7983b4b38a175f044a7f9bce26f04f295112413dbc08.json b/.sqlx/query-438b5a20e4b8369cb5cf7983b4b38a175f044a7f9bce26f04f295112413dbc08.json new file mode 100644 index 00000000..fab6bb93 --- /dev/null +++ b/.sqlx/query-438b5a20e4b8369cb5cf7983b4b38a175f044a7f9bce26f04f295112413dbc08.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "UPDATE EVENTS\n SET description_html = ?,\n description_updated_at = CURRENT_TIMESTAMP,\n updated_at = CURRENT_TIMESTAMP\n WHERE id = ?\n RETURNING description_updated_at", + "describe": { + "columns": [ + { + "name": "description_updated_at", + "ordinal": 0, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + true + ] + }, + "hash": "438b5a20e4b8369cb5cf7983b4b38a175f044a7f9bce26f04f295112413dbc08" +} diff --git a/.sqlx/query-448e89f870ac5910a4276f3e3ad3c6c83918a581d3827af2bf638522200a12e3.json b/.sqlx/query-448e89f870ac5910a4276f3e3ad3c6c83918a581d3827af2bf638522200a12e3.json new file mode 100644 index 00000000..d5117075 --- /dev/null +++ b/.sqlx/query-448e89f870ac5910a4276f3e3ad3c6c83918a581d3827af2bf638522200a12e3.json @@ -0,0 +1,92 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT e.*, u.email as address\n FROM emails e\n JOIN users u ON u.id = e.user_id\n WHERE e.post_id = ? AND e.list_id = ?\n AND e.sent_at IS NULL\n AND e.id = (\n SELECT MAX(ee.id)\n FROM emails ee\n WHERE ee.user_id = e.user_id\n AND ee.post_id = e.post_id\n AND ee.list_id = e.list_id\n AND ee.sent_at IS NULL\n );\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "kind", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "user_version", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "post_id", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "list_id", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "event_id", + "ordinal": 6, + "type_info": "Integer" + }, + { + "name": "notification_id", + "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "error", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 9, + "type_info": "Datetime" + }, + { + "name": "sent_at", + "ordinal": 10, + "type_info": "Datetime" + }, + { + "name": "opened_at", + "ordinal": 11, + "type_info": "Datetime" + }, + { + "name": "address", + "ordinal": 12, + "type_info": "Text" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + true, + false, + true, + true, + false + ] + }, + "hash": "448e89f870ac5910a4276f3e3ad3c6c83918a581d3827af2bf638522200a12e3" +} diff --git a/.sqlx/query-45c365566ab12e5c6d67e50cbe40ca1ccfa427b41a89154308b6d36712580931.json b/.sqlx/query-45c365566ab12e5c6d67e50cbe40ca1ccfa427b41a89154308b6d36712580931.json new file mode 100644 index 00000000..b7383183 --- /dev/null +++ b/.sqlx/query-45c365566ab12e5c6d67e50cbe40ca1ccfa427b41a89154308b6d36712580931.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM rsvps\n WHERE id = (\n SELECT r.id FROM rsvps r\n JOIN rsvp_sessions rs ON rs.id = r.session_id\n WHERE rs.event_id = ? AND r.user_id = ?\n AND rs.status IN ('payment_pending', 'payment_confirmed')\n )", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "45c365566ab12e5c6d67e50cbe40ca1ccfa427b41a89154308b6d36712580931" +} diff --git a/.sqlx/query-4811be1c96dc51a26398b8a7a3027302059bee965cc964688415822f1f651319.json b/.sqlx/query-4811be1c96dc51a26398b8a7a3027302059bee965cc964688415822f1f651319.json new file mode 100644 index 00000000..4bf2bf2d --- /dev/null +++ b/.sqlx/query-4811be1c96dc51a26398b8a7a3027302059bee965cc964688415822f1f651319.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE flyers SET image_data = ?, image_version = image_version + 1, link_url = ?, flyer_name = ? WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "4811be1c96dc51a26398b8a7a3027302059bee965cc964688415822f1f651319" +} diff --git a/.sqlx/query-4ce47d4be7d0a9708fa72a91373c9cb455de918b47d50dd90a084be780f038aa.json b/.sqlx/query-4ce47d4be7d0a9708fa72a91373c9cb455de918b47d50dd90a084be780f038aa.json new file mode 100644 index 00000000..59d8c3d2 --- /dev/null +++ b/.sqlx/query-4ce47d4be7d0a9708fa72a91373c9cb455de918b47d50dd90a084be780f038aa.json @@ -0,0 +1,68 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n u.*,\n COALESCE(MAX(h.version), 0) as \"version!: i64\",\n COALESCE(GROUP_CONCAT(r.role), '') AS \"roles!: String\"\n FROM users u\n LEFT JOIN user_roles r ON r.user_id = u.id\n JOIN user_history h ON h.user_id = u.id\n WHERE u.id = ?\n GROUP BY u.id\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "email", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "first_name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "last_name", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "phone", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 5, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 6, + "type_info": "Datetime" + }, + { + "name": "version!: i64", + "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "roles!: String", + "ordinal": 8, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + true, + true, + true, + true, + true, + true, + false, + false + ] + }, + "hash": "4ce47d4be7d0a9708fa72a91373c9cb455de918b47d50dd90a084be780f038aa" +} diff --git a/.sqlx/query-4d491f9fa61509759db0864629f2c38d4a895451f4a16bde94c601f5bee1c2e7.json b/.sqlx/query-4d491f9fa61509759db0864629f2c38d4a895451f4a16bde94c601f5bee1c2e7.json new file mode 100644 index 00000000..14b0628c --- /dev/null +++ b/.sqlx/query-4d491f9fa61509759db0864629f2c38d4a895451f4a16bde94c601f5bee1c2e7.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "SELECT\n s.name AS spot_name,\n u.first_name AS \"first_name!: String\",\n u.last_name AS \"last_name!: String\",\n u.email,\n u.phone,\n r.contribution\n FROM rsvps r\n JOIN spots s ON s.id = r.spot_id\n JOIN rsvp_sessions rs ON rs.id = r.session_id\n JOIN users u ON u.id = r.user_id\n WHERE rs.id = ?\n ", + "describe": { + "columns": [ + { + "name": "spot_name", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "first_name!: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "last_name!: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "email", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "phone", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "contribution", + "ordinal": 5, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true, + true, + false, + true, + false + ] + }, + "hash": "4d491f9fa61509759db0864629f2c38d4a895451f4a16bde94c601f5bee1c2e7" +} diff --git a/.sqlx/query-4ecdbeb54b73a990ece2add07422a6a6ab2f8c2dae1bdc2b8d8e13970240078a.json b/.sqlx/query-4ecdbeb54b73a990ece2add07422a6a6ab2f8c2dae1bdc2b8d8e13970240078a.json new file mode 100644 index 00000000..1f3b9e29 --- /dev/null +++ b/.sqlx/query-4ecdbeb54b73a990ece2add07422a6a6ab2f8c2dae1bdc2b8d8e13970240078a.json @@ -0,0 +1,68 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n u.*,\n COALESCE(MAX(h.version), 0) as \"version!: i64\",\n COALESCE(GROUP_CONCAT(r.role), '') AS \"roles!: String\"\n FROM list_members lm\n JOIN users u ON u.id = lm.user_id\n JOIN user_history h ON h.user_id = u.id\n LEFT JOIN user_roles r ON r.user_id = u.id\n WHERE lm.list_id = ?\n GROUP BY u.id\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "email", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "first_name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "last_name", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "phone", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 5, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 6, + "type_info": "Datetime" + }, + { + "name": "version!: i64", + "ordinal": 7, + "type_info": "Null" + }, + { + "name": "roles!: String", + "ordinal": 8, + "type_info": "Null" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + true, + true, + true, + false, + false, + null, + null + ] + }, + "hash": "4ecdbeb54b73a990ece2add07422a6a6ab2f8c2dae1bdc2b8d8e13970240078a" +} diff --git a/.sqlx/query-50e1f7b588284eb400ad205e7610d44f7fec238df32b4aaae914da312a1ad3c4.json b/.sqlx/query-50e1f7b588284eb400ad205e7610d44f7fec238df32b4aaae914da312a1ad3c4.json new file mode 100644 index 00000000..2b388641 --- /dev/null +++ b/.sqlx/query-50e1f7b588284eb400ad205e7610d44f7fec238df32b4aaae914da312a1ad3c4.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "SELECT l.*, COUNT(m.list_id) AS count\n FROM lists l\n LEFT JOIN list_members m ON l.id = m.list_id\n GROUP BY l.id", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 3, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 4, + "type_info": "Datetime" + }, + { + "name": "count", + "ordinal": 5, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "50e1f7b588284eb400ad205e7610d44f7fec238df32b4aaae914da312a1ad3c4" +} diff --git a/.sqlx/query-51bd9fbfb49bab204a284890d6c8a4bba9cd4f8095ea5e18b433dab452cbecfb.json b/.sqlx/query-51bd9fbfb49bab204a284890d6c8a4bba9cd4f8095ea5e18b433dab452cbecfb.json new file mode 100644 index 00000000..9e7daad2 --- /dev/null +++ b/.sqlx/query-51bd9fbfb49bab204a284890d6c8a4bba9cd4f8095ea5e18b433dab452cbecfb.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO manual_rsvps (event_id, user_id, creator_user_id) VALUES (?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "51bd9fbfb49bab204a284890d6c8a4bba9cd4f8095ea5e18b433dab452cbecfb" +} diff --git a/.sqlx/query-546ee26d35bfca0143a11502e5aa6baa52b75fa21f031a19d09e7a693400fceb.json b/.sqlx/query-546ee26d35bfca0143a11502e5aa6baa52b75fa21f031a19d09e7a693400fceb.json new file mode 100644 index 00000000..b7353d06 --- /dev/null +++ b/.sqlx/query-546ee26d35bfca0143a11502e5aa6baa52b75fa21f031a19d09e7a693400fceb.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM event_spots WHERE event_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "546ee26d35bfca0143a11502e5aa6baa52b75fa21f031a19d09e7a693400fceb" +} diff --git a/.sqlx/query-56772e16ee9e6e011c62533dabba11808fd31342fdea7b16f449c592e7e55b75.json b/.sqlx/query-56772e16ee9e6e011c62533dabba11808fd31342fdea7b16f449c592e7e55b75.json new file mode 100644 index 00000000..405e44c5 --- /dev/null +++ b/.sqlx/query-56772e16ee9e6e011c62533dabba11808fd31342fdea7b16f449c592e7e55b75.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE flyers SET image_data = ?, image_version = image_version + 1, link_url = ?, flyer_name = ? WHERE id = ? AND user_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "56772e16ee9e6e011c62533dabba11808fd31342fdea7b16f449c592e7e55b75" +} diff --git a/.sqlx/query-5931fda3449b5465ff6555597b812b69b79de2bbb75428543f0170d31f54226c.json b/.sqlx/query-5931fda3449b5465ff6555597b812b69b79de2bbb75428543f0170d31f54226c.json new file mode 100644 index 00000000..2298a135 --- /dev/null +++ b/.sqlx/query-5931fda3449b5465ff6555597b812b69b79de2bbb75428543f0170d31f54226c.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO session_tokens (user_id, token) VALUES (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "5931fda3449b5465ff6555597b812b69b79de2bbb75428543f0170d31f54226c" +} diff --git a/.sqlx/query-59d5c9a9e5bb2050b1d49479f98529acb56e0d7d503a369370db69028dcd7c59.json b/.sqlx/query-59d5c9a9e5bb2050b1d49479f98529acb56e0d7d503a369370db69028dcd7c59.json new file mode 100644 index 00000000..ef31b12f --- /dev/null +++ b/.sqlx/query-59d5c9a9e5bb2050b1d49479f98529acb56e0d7d503a369370db69028dcd7c59.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM login_tokens WHERE token = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "59d5c9a9e5bb2050b1d49479f98529acb56e0d7d503a369370db69028dcd7c59" +} diff --git a/.sqlx/query-5b868fbed64f89d15443811a075111f5da61d305b2960efcedba4727f9dde132.json b/.sqlx/query-5b868fbed64f89d15443811a075111f5da61d305b2960efcedba4727f9dde132.json new file mode 100644 index 00000000..3797993f --- /dev/null +++ b/.sqlx/query-5b868fbed64f89d15443811a075111f5da61d305b2960efcedba4727f9dde132.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT event_id FROM manual_rsvps WHERE event_id = ? AND user_id = ?", + "describe": { + "columns": [ + { + "name": "event_id", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false + ] + }, + "hash": "5b868fbed64f89d15443811a075111f5da61d305b2960efcedba4727f9dde132" +} diff --git a/.sqlx/query-5d625c30e6fe84c7b6d770fd36b4da7a91913981e2b835f9eb79599768cd0507.json b/.sqlx/query-5d625c30e6fe84c7b6d770fd36b4da7a91913981e2b835f9eb79599768cd0507.json new file mode 100644 index 00000000..50d93092 --- /dev/null +++ b/.sqlx/query-5d625c30e6fe84c7b6d770fd36b4da7a91913981e2b835f9eb79599768cd0507.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO event_flyers (event_id, width, height, image_full, image_lg, image_md, image_sm, image_thumb, updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)\n ON CONFLICT(event_id) DO UPDATE SET\n width = excluded.width,\n height = excluded.height,\n image_full = excluded.image_full,\n image_lg = excluded.image_lg,\n image_md = excluded.image_md,\n image_sm = excluded.image_sm,\n image_thumb = excluded.image_thumb,\n updated_at = CURRENT_TIMESTAMP", + "describe": { + "columns": [], + "parameters": { + "Right": 8 + }, + "nullable": [] + }, + "hash": "5d625c30e6fe84c7b6d770fd36b4da7a91913981e2b835f9eb79599768cd0507" +} diff --git a/.sqlx/query-609218ed0e9d62fe4095afa7dac39774b584d3ce23442d907e394000e0078aa5.json b/.sqlx/query-609218ed0e9d62fe4095afa7dac39774b584d3ce23442d907e394000e0078aa5.json new file mode 100644 index 00000000..98f958d6 --- /dev/null +++ b/.sqlx/query-609218ed0e9d62fe4095afa7dac39774b584d3ce23442d907e394000e0078aa5.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM posts WHERE slug = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "title", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "slug", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "author", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "content", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 5, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 6, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "609218ed0e9d62fe4095afa7dac39774b584d3ce23442d907e394000e0078aa5" +} diff --git a/.sqlx/query-60cd2c697c3bd1c6f6e2f141dd13ca75728bbe852ddb74bf16ebeb349a68c6c2.json b/.sqlx/query-60cd2c697c3bd1c6f6e2f141dd13ca75728bbe852ddb74bf16ebeb349a68c6c2.json new file mode 100644 index 00000000..297bf515 --- /dev/null +++ b/.sqlx/query-60cd2c697c3bd1c6f6e2f141dd13ca75728bbe852ddb74bf16ebeb349a68c6c2.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT flyer_name, link_url FROM flyers WHERE id = ?", + "describe": { + "columns": [ + { + "name": "flyer_name", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "link_url", + "ordinal": 1, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true + ] + }, + "hash": "60cd2c697c3bd1c6f6e2f141dd13ca75728bbe852ddb74bf16ebeb349a68c6c2" +} diff --git a/.sqlx/query-6116973b9fda9214835875fbfa0bb8bc892795a1e74d8a243a2f8398abe020ce.json b/.sqlx/query-6116973b9fda9214835875fbfa0bb8bc892795a1e74d8a243a2f8398abe020ce.json new file mode 100644 index 00000000..c9bb8ade --- /dev/null +++ b/.sqlx/query-6116973b9fda9214835875fbfa0bb8bc892795a1e74d8a243a2f8398abe020ce.json @@ -0,0 +1,98 @@ +{ + "db_name": "SQLite", + "query": "SELECT s.*\n FROM spots s\n JOIN event_spots es ON es.spot_id = s.id\n WHERE es.event_id = ?\n ORDER BY s.sort\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "qty_total", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "qty_per_person", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "kind", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "sort", + "ordinal": 6, + "type_info": "Integer" + }, + { + "name": "required_contribution", + "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "min_contribution", + "ordinal": 8, + "type_info": "Integer" + }, + { + "name": "max_contribution", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "suggested_contribution", + "ordinal": 10, + "type_info": "Integer" + }, + { + "name": "required_notice_hours", + "ordinal": 11, + "type_info": "Integer" + }, + { + "name": "created_at", + "ordinal": 12, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 13, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + false, + false + ] + }, + "hash": "6116973b9fda9214835875fbfa0bb8bc892795a1e74d8a243a2f8398abe020ce" +} diff --git a/.sqlx/query-64aad7bcc2c410d6e2444b70284f6f40f8bf3b172e50130f4160775f9ee21cda.json b/.sqlx/query-64aad7bcc2c410d6e2444b70284f6f40f8bf3b172e50130f4160775f9ee21cda.json new file mode 100644 index 00000000..0da51502 --- /dev/null +++ b/.sqlx/query-64aad7bcc2c410d6e2444b70284f6f40f8bf3b172e50130f4160775f9ee21cda.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM rsvps WHERE session_id IN (SELECT id FROM rsvp_sessions WHERE event_id = ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "64aad7bcc2c410d6e2444b70284f6f40f8bf3b172e50130f4160775f9ee21cda" +} diff --git a/.sqlx/query-6591de462498f61ff7bec9060b7f3c70cc8c9a12cc83da776e5adb880722d588.json b/.sqlx/query-6591de462498f61ff7bec9060b7f3c70cc8c9a12cc83da776e5adb880722d588.json new file mode 100644 index 00000000..752e2128 --- /dev/null +++ b/.sqlx/query-6591de462498f61ff7bec9060b7f3c70cc8c9a12cc83da776e5adb880722d588.json @@ -0,0 +1,68 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n u.*,\n COALESCE(MAX(h.version), 0) as \"version!: i64\",\n COALESCE(GROUP_CONCAT(r.role), '') AS \"roles!: String\"\n FROM users u\n LEFT JOIN login_tokens t ON t.user_id = u.id\n JOIN user_history h ON h.user_id = u.id\n LEFT JOIN user_roles r ON r.user_id = u.id\n WHERE t.token = ?\n GROUP BY u.id", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "email", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "first_name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "last_name", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "phone", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 5, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 6, + "type_info": "Datetime" + }, + { + "name": "version!: i64", + "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "roles!: String", + "ordinal": 8, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + true, + true, + true, + true, + true, + true, + false, + false + ] + }, + "hash": "6591de462498f61ff7bec9060b7f3c70cc8c9a12cc83da776e5adb880722d588" +} diff --git a/.sqlx/query-65b0fb0f1eaf168d2f844f2d8289c342be6fc0bee69226b11c4be9a4e4d75fd4.json b/.sqlx/query-65b0fb0f1eaf168d2f844f2d8289c342be6fc0bee69226b11c4be9a4e4d75fd4.json new file mode 100644 index 00000000..17c90b87 --- /dev/null +++ b/.sqlx/query-65b0fb0f1eaf168d2f844f2d8289c342be6fc0bee69226b11c4be9a4e4d75fd4.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE rsvps SET checkin_at = NULL\n WHERE id = (\n SELECT r.id FROM rsvps r\n JOIN rsvp_sessions rs ON rs.id = r.session_id\n WHERE rs.event_id = ? AND r.user_id = ?\n AND rs.status IN ('payment_pending', 'payment_confirmed')\n )", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "65b0fb0f1eaf168d2f844f2d8289c342be6fc0bee69226b11c4be9a4e4d75fd4" +} diff --git a/.sqlx/query-67bf23b0d967554319266971cffe04660b3069c84d33f41b48bf045247781299.json b/.sqlx/query-67bf23b0d967554319266971cffe04660b3069c84d33f41b48bf045247781299.json new file mode 100644 index 00000000..ab666928 --- /dev/null +++ b/.sqlx/query-67bf23b0d967554319266971cffe04660b3069c84d33f41b48bf045247781299.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE events\n SET title = ?,\n slug = ?,\n start = ?,\n end = ?,\n capacity = ?,\n unlisted = ?,\n closed = ?,\n guest_list_id = ?,\n spots_per_person = ?\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 10 + }, + "nullable": [] + }, + "hash": "67bf23b0d967554319266971cffe04660b3069c84d33f41b48bf045247781299" +} diff --git a/.sqlx/query-681f195e9c1bc39e22a362e4612d936a00555a5d345ffbf71e0b03219d36d93f.json b/.sqlx/query-681f195e9c1bc39e22a362e4612d936a00555a5d345ffbf71e0b03219d36d93f.json new file mode 100644 index 00000000..38d8ac5f --- /dev/null +++ b/.sqlx/query-681f195e9c1bc39e22a362e4612d936a00555a5d345ffbf71e0b03219d36d93f.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n l.id,\n l.name,\n COUNT(lm.user_id) AS count,\n SUM(\n CASE WHEN EXISTS (\n SELECT 1\n FROM emails e\n WHERE e.user_id = u.id\n AND e.list_id = l.id\n AND e.post_id = ?\n AND e.sent_at IS NOT NULL\n )\n THEN 1 ELSE 0 END\n ) AS sent\n FROM lists l\n LEFT JOIN list_members lm ON lm.list_id = l.id\n LEFT JOIN users u ON u.id = lm.user_id\n GROUP BY l.id;\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "count", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "sent", + "ordinal": 3, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "681f195e9c1bc39e22a362e4612d936a00555a5d345ffbf71e0b03219d36d93f" +} diff --git a/.sqlx/query-69515bd8402e2b5fe578acb5a0571e3db9f1557cbf3c38e744d68c00ac45cda7.json b/.sqlx/query-69515bd8402e2b5fe578acb5a0571e3db9f1557cbf3c38e744d68c00ac45cda7.json new file mode 100644 index 00000000..93451e17 --- /dev/null +++ b/.sqlx/query-69515bd8402e2b5fe578acb5a0571e3db9f1557cbf3c38e744d68c00ac45cda7.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM notifications WHERE id = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "content", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 3, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 4, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "69515bd8402e2b5fe578acb5a0571e3db9f1557cbf3c38e744d68c00ac45cda7" +} diff --git a/.sqlx/query-6b33153cce8526cfdd8d8a7264665c6aad5dc3698898381a7ea58580ce1787c9.json b/.sqlx/query-6b33153cce8526cfdd8d8a7264665c6aad5dc3698898381a7ea58580ce1787c9.json new file mode 100644 index 00000000..15960484 --- /dev/null +++ b/.sqlx/query-6b33153cce8526cfdd8d8a7264665c6aad5dc3698898381a7ea58580ce1787c9.json @@ -0,0 +1,68 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n u.*,\n COALESCE(MAX(h.version), 0) as \"version!: i64\",\n COALESCE(GROUP_CONCAT(r.role), '') AS \"roles!: String\"\n FROM users u\n LEFT JOIN user_roles r ON r.user_id = u.id\n JOIN user_history h ON h.user_id = u.id\n WHERE u.email = ? COLLATE NOCASE\n GROUP BY u.id\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "email", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "first_name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "last_name", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "phone", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 5, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 6, + "type_info": "Datetime" + }, + { + "name": "version!: i64", + "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "roles!: String", + "ordinal": 8, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + true, + true, + true, + true, + true, + true, + false, + false + ] + }, + "hash": "6b33153cce8526cfdd8d8a7264665c6aad5dc3698898381a7ea58580ce1787c9" +} diff --git a/.sqlx/query-71a23021623175a1fc41826a3fa6864f0ce11920d5926a5c87b16e94dbc3c116.json b/.sqlx/query-71a23021623175a1fc41826a3fa6864f0ce11920d5926a5c87b16e94dbc3c116.json new file mode 100644 index 00000000..f2680509 --- /dev/null +++ b/.sqlx/query-71a23021623175a1fc41826a3fa6864f0ce11920d5926a5c87b16e94dbc3c116.json @@ -0,0 +1,164 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM events WHERE slug = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "title", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "slug", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "start", + "ordinal": 3, + "type_info": "Datetime" + }, + { + "name": "end", + "ordinal": 4, + "type_info": "Datetime" + }, + { + "name": "capacity", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "unlisted", + "ordinal": 6, + "type_info": "Bool" + }, + { + "name": "closed", + "ordinal": 7, + "type_info": "Bool" + }, + { + "name": "guest_list_id", + "ordinal": 8, + "type_info": "Integer" + }, + { + "name": "spots_per_person", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "description_html", + "ordinal": 10, + "type_info": "Text" + }, + { + "name": "description_updated_at", + "ordinal": 11, + "type_info": "Datetime" + }, + { + "name": "invite_subject", + "ordinal": 12, + "type_info": "Text" + }, + { + "name": "invite_html", + "ordinal": 13, + "type_info": "Text" + }, + { + "name": "invite_updated_at", + "ordinal": 14, + "type_info": "Datetime" + }, + { + "name": "invite_sent_at", + "ordinal": 15, + "type_info": "Datetime" + }, + { + "name": "confirmation_subject", + "ordinal": 16, + "type_info": "Text" + }, + { + "name": "confirmation_html", + "ordinal": 17, + "type_info": "Text" + }, + { + "name": "confirmation_updated_at", + "ordinal": 18, + "type_info": "Datetime" + }, + { + "name": "dayof_subject", + "ordinal": 19, + "type_info": "Text" + }, + { + "name": "dayof_html", + "ordinal": 20, + "type_info": "Text" + }, + { + "name": "dayof_updated_at", + "ordinal": 21, + "type_info": "Datetime" + }, + { + "name": "dayof_sent_at", + "ordinal": 22, + "type_info": "Datetime" + }, + { + "name": "created_at", + "ordinal": 23, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 24, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false + ] + }, + "hash": "71a23021623175a1fc41826a3fa6864f0ce11920d5926a5c87b16e94dbc3c116" +} diff --git a/.sqlx/query-71e7cdb741e538618f993ab62cbad715cb8763a618697795dcbdbe904ca2a2ca.json b/.sqlx/query-71e7cdb741e538618f993ab62cbad715cb8763a618697795dcbdbe904ca2a2ca.json new file mode 100644 index 00000000..1afa130e --- /dev/null +++ b/.sqlx/query-71e7cdb741e538618f993ab62cbad715cb8763a618697795dcbdbe904ca2a2ca.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM flyers WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "71e7cdb741e538618f993ab62cbad715cb8763a618697795dcbdbe904ca2a2ca" +} diff --git a/.sqlx/query-79301b44b77802e0096efd73b1e9adac27b27a3cf7bf853af3a9f130b1684d91.json b/.sqlx/query-79301b44b77802e0096efd73b1e9adac27b27a3cf7bf853af3a9f130b1684d91.json new file mode 100644 index 00000000..6aa292f3 --- /dev/null +++ b/.sqlx/query-79301b44b77802e0096efd73b1e9adac27b27a3cf7bf853af3a9f130b1684d91.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM posts WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "79301b44b77802e0096efd73b1e9adac27b27a3cf7bf853af3a9f130b1684d91" +} diff --git a/.sqlx/query-7b6c87a24f30c9a28aa9a0a5af491e84867de40a49086c7e595bb24d9db8dfb0.json b/.sqlx/query-7b6c87a24f30c9a28aa9a0a5af491e84867de40a49086c7e595bb24d9db8dfb0.json new file mode 100644 index 00000000..f28a5bbd --- /dev/null +++ b/.sqlx/query-7b6c87a24f30c9a28aa9a0a5af491e84867de40a49086c7e595bb24d9db8dfb0.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT spot_id FROM event_spots WHERE event_id = ?", + "describe": { + "columns": [ + { + "name": "spot_id", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "7b6c87a24f30c9a28aa9a0a5af491e84867de40a49086c7e595bb24d9db8dfb0" +} diff --git a/.sqlx/query-7ec130e72c1adfcbee619ce3527d3484f7674d64094c43760ea0ce33c18863d9.json b/.sqlx/query-7ec130e72c1adfcbee619ce3527d3484f7674d64094c43760ea0ce33c18863d9.json new file mode 100644 index 00000000..cdc2f7c3 --- /dev/null +++ b/.sqlx/query-7ec130e72c1adfcbee619ce3527d3484f7674d64094c43760ea0ce33c18863d9.json @@ -0,0 +1,92 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT e.*, u.email as address FROM emails e\n JOIN users u ON u.id = e.user_id\n WHERE e.kind = ? AND e.event_id = ?\n AND ifnull(e.sent_at, '') = (\n SELECT ifnull(MAX(ee.sent_at), '')\n FROM emails ee\n WHERE ee.kind = e.kind\n AND ee.user_id = e.user_id\n AND ee.event_id = e.event_id\n );\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "kind", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "user_version", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "post_id", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "list_id", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "event_id", + "ordinal": 6, + "type_info": "Integer" + }, + { + "name": "notification_id", + "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "error", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 9, + "type_info": "Datetime" + }, + { + "name": "sent_at", + "ordinal": 10, + "type_info": "Datetime" + }, + { + "name": "opened_at", + "ordinal": 11, + "type_info": "Datetime" + }, + { + "name": "address", + "ordinal": 12, + "type_info": "Text" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + true, + false, + true, + true, + false + ] + }, + "hash": "7ec130e72c1adfcbee619ce3527d3484f7674d64094c43760ea0ce33c18863d9" +} diff --git a/.sqlx/query-7fcbeef9dea50c25ceddf32c9f817f8527d7bca1554989625aee8fba037fc032.json b/.sqlx/query-7fcbeef9dea50c25ceddf32c9f817f8527d7bca1554989625aee8fba037fc032.json new file mode 100644 index 00000000..19adbd4b --- /dev/null +++ b/.sqlx/query-7fcbeef9dea50c25ceddf32c9f817f8527d7bca1554989625aee8fba037fc032.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "SELECT *\n FROM lists\n WHERE id = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 3, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 4, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "7fcbeef9dea50c25ceddf32c9f817f8527d7bca1554989625aee8fba037fc032" +} diff --git a/.sqlx/query-8216bbdc55757caa3518538f6a58c1650613561d1d2c69109aba9e19658e1ff2.json b/.sqlx/query-8216bbdc55757caa3518538f6a58c1650613561d1d2c69109aba9e19658e1ff2.json new file mode 100644 index 00000000..824a555e --- /dev/null +++ b/.sqlx/query-8216bbdc55757caa3518538f6a58c1650613561d1d2c69109aba9e19658e1ff2.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE events SET invite_sent_at = CURRENT_TIMESTAMP WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "8216bbdc55757caa3518538f6a58c1650613561d1d2c69109aba9e19658e1ff2" +} diff --git a/.sqlx/query-83a26b44d1541ee06452d77d91e90fbb7439a75e23f6d0d80673f8f457075181.json b/.sqlx/query-83a26b44d1541ee06452d77d91e90fbb7439a75e23f6d0d80673f8f457075181.json new file mode 100644 index 00000000..961eeea2 --- /dev/null +++ b/.sqlx/query-83a26b44d1541ee06452d77d91e90fbb7439a75e23f6d0d80673f8f457075181.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO spots\n (name, description, qty_total, qty_per_person, kind, sort, required_contribution, min_contribution, max_contribution, suggested_contribution, required_notice_hours)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 11 + }, + "nullable": [] + }, + "hash": "83a26b44d1541ee06452d77d91e90fbb7439a75e23f6d0d80673f8f457075181" +} diff --git a/.sqlx/query-83ca7260a64c6c3e0af6e636f165c67bd8a1a6d39b37f8ada427431ee8000c00.json b/.sqlx/query-83ca7260a64c6c3e0af6e636f165c67bd8a1a6d39b37f8ada427431ee8000c00.json new file mode 100644 index 00000000..4bc56275 --- /dev/null +++ b/.sqlx/query-83ca7260a64c6c3e0af6e636f165c67bd8a1a6d39b37f8ada427431ee8000c00.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE spots\n SET name = ?,\n description = ?,\n qty_total = ?,\n qty_per_person = ?,\n kind = ?,\n sort = ?,\n required_contribution = ?,\n min_contribution = ?,\n max_contribution = ?,\n suggested_contribution = ?,\n required_notice_hours = ?,\n updated_at = CURRENT_TIMESTAMP\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 12 + }, + "nullable": [] + }, + "hash": "83ca7260a64c6c3e0af6e636f165c67bd8a1a6d39b37f8ada427431ee8000c00" +} diff --git a/.sqlx/query-83f2fdce3d64410e3182cd4da61717c30574e0f533c11529aa87ee9722c90dc2.json b/.sqlx/query-83f2fdce3d64410e3182cd4da61717c30574e0f533c11529aa87ee9722c90dc2.json new file mode 100644 index 00000000..912ac4bd --- /dev/null +++ b/.sqlx/query-83f2fdce3d64410e3182cd4da61717c30574e0f533c11529aa87ee9722c90dc2.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE emails SET opened_at = CURRENT_TIMESTAMP\n WHERE id = ? AND opened_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "83f2fdce3d64410e3182cd4da61717c30574e0f533c11529aa87ee9722c90dc2" +} diff --git a/.sqlx/query-853ab923488234a004c19e4df0ceb64909de30e6fa178223971e419910e7e773.json b/.sqlx/query-853ab923488234a004c19e4df0ceb64909de30e6fa178223971e419910e7e773.json new file mode 100644 index 00000000..fa4ebbbe --- /dev/null +++ b/.sqlx/query-853ab923488234a004c19e4df0ceb64909de30e6fa178223971e419910e7e773.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT EXISTS(\n SELECT 1 FROM rsvps r\n JOIN rsvp_sessions rs ON rs.id = r.session_id\n WHERE rs.event_id = ? AND r.user_id = ?\n AND rs.status IN ('payment_pending', 'payment_confirmed')\n ) as \"exists!: bool\"", + "describe": { + "columns": [ + { + "name": "exists!: bool", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false + ] + }, + "hash": "853ab923488234a004c19e4df0ceb64909de30e6fa178223971e419910e7e773" +} diff --git a/.sqlx/query-8649469bf616905877dc5f3ef25cc8920661651aefc69aa7b60ad17ae49ac89a.json b/.sqlx/query-8649469bf616905877dc5f3ef25cc8920661651aefc69aa7b60ad17ae49ac89a.json new file mode 100644 index 00000000..d7985772 --- /dev/null +++ b/.sqlx/query-8649469bf616905877dc5f3ef25cc8920661651aefc69aa7b60ad17ae49ac89a.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO spots\n (name, description, qty_total, qty_per_person, kind, sort,\n required_contribution, min_contribution, max_contribution,\n suggested_contribution, required_notice_hours)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 11 + }, + "nullable": [] + }, + "hash": "8649469bf616905877dc5f3ef25cc8920661651aefc69aa7b60ad17ae49ac89a" +} diff --git a/.sqlx/query-8874dee6c8edad6f2a4512a86a22c85ee06c07f751973f171adf10b35b515d87.json b/.sqlx/query-8874dee6c8edad6f2a4512a86a22c85ee06c07f751973f171adf10b35b515d87.json new file mode 100644 index 00000000..df18968b --- /dev/null +++ b/.sqlx/query-8874dee6c8edad6f2a4512a86a22c85ee06c07f751973f171adf10b35b515d87.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n COUNT(r.user_id) AS count,\n COALESCE(SUM(\n CASE WHEN EXISTS (\n SELECT 1\n FROM emails e\n WHERE e.kind = ?\n AND e.user_id = r.user_id\n AND e.event_id = rs.event_id\n AND e.sent_at IS NOT NULL\n )\n THEN 1 ELSE 0 END\n ), 0) AS sent\n FROM rsvps r\n JOIN rsvp_sessions rs ON rs.id = r.session_id\n WHERE rs.event_id = ?\n AND (rs.status = ? OR rs.status = ?)\n ", + "describe": { + "columns": [ + { + "name": "count", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "sent", + "ordinal": 1, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 4 + }, + "nullable": [ + false, + false + ] + }, + "hash": "8874dee6c8edad6f2a4512a86a22c85ee06c07f751973f171adf10b35b515d87" +} diff --git a/.sqlx/query-88c6c86f47590ec8adb40dd4ef707788a3c4aea369a79259c512cdffc23ad7db.json b/.sqlx/query-88c6c86f47590ec8adb40dd4ef707788a3c4aea369a79259c512cdffc23ad7db.json new file mode 100644 index 00000000..0bb72c11 --- /dev/null +++ b/.sqlx/query-88c6c86f47590ec8adb40dd4ef707788a3c4aea369a79259c512cdffc23ad7db.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE rsvps\n SET user_id = ?,\n user_version = ?,\n updated_at = CURRENT_TIMESTAMP\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "88c6c86f47590ec8adb40dd4ef707788a3c4aea369a79259c512cdffc23ad7db" +} diff --git a/.sqlx/query-88d548ebe002ab441f26ca7c1c8eb3dfe6bed7331e45b009efdd06aebca86a72.json b/.sqlx/query-88d548ebe002ab441f26ca7c1c8eb3dfe6bed7331e45b009efdd06aebca86a72.json new file mode 100644 index 00000000..e5a9a3d7 --- /dev/null +++ b/.sqlx/query-88d548ebe002ab441f26ca7c1c8eb3dfe6bed7331e45b009efdd06aebca86a72.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO emails (kind, user_id, user_version) VALUES (?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "88d548ebe002ab441f26ca7c1c8eb3dfe6bed7331e45b009efdd06aebca86a72" +} diff --git a/.sqlx/query-8b2901c2986e903b41284ecfd5d4d593cfb716359c2c464bcff71ce876d9ea04.json b/.sqlx/query-8b2901c2986e903b41284ecfd5d4d593cfb716359c2c464bcff71ce876d9ea04.json new file mode 100644 index 00000000..459fc94a --- /dev/null +++ b/.sqlx/query-8b2901c2986e903b41284ecfd5d4d593cfb716359c2c464bcff71ce876d9ea04.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT image_data FROM flyers WHERE id = ?", + "describe": { + "columns": [ + { + "name": "image_data", + "ordinal": 0, + "type_info": "Blob" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "8b2901c2986e903b41284ecfd5d4d593cfb716359c2c464bcff71ce876d9ea04" +} diff --git a/.sqlx/query-8c9838143d95813046f750a6056d3fc34ee65fb399813097facc87082c89b26e.json b/.sqlx/query-8c9838143d95813046f750a6056d3fc34ee65fb399813097facc87082c89b26e.json new file mode 100644 index 00000000..00b43b65 --- /dev/null +++ b/.sqlx/query-8c9838143d95813046f750a6056d3fc34ee65fb399813097facc87082c89b26e.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO lists\n (name, description)\n VALUES (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "8c9838143d95813046f750a6056d3fc34ee65fb399813097facc87082c89b26e" +} diff --git a/.sqlx/query-904ebe16d0f2c64b9e640f2724d84cd930cf530ae70f29c677a26d79d7828684.json b/.sqlx/query-904ebe16d0f2c64b9e640f2724d84cd930cf530ae70f29c677a26d79d7828684.json new file mode 100644 index 00000000..fde4bfbe --- /dev/null +++ b/.sqlx/query-904ebe16d0f2c64b9e640f2724d84cd930cf530ae70f29c677a26d79d7828684.json @@ -0,0 +1,92 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO emails (kind, user_id, user_version, post_id, list_id)\n SELECT ?, u.id, uh.version, ?, lm.list_id\n FROM list_members lm\n JOIN users u ON u.id = lm.user_id\n JOIN user_history uh ON uh.user_id = u.id\n WHERE lm.list_id = ?\n AND NOT EXISTS (\n SELECT 1\n FROM emails ee\n WHERE ee.user_id = u.id\n AND ee.post_id = ?\n AND ee.list_id = lm.list_id\n )\n RETURNING *, (\n SELECT u.email FROM users u\n WHERE u.id = emails.user_id\n ) AS address\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "kind", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "user_version", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "post_id", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "list_id", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "event_id", + "ordinal": 6, + "type_info": "Integer" + }, + { + "name": "notification_id", + "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "error", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 9, + "type_info": "Datetime" + }, + { + "name": "sent_at", + "ordinal": 10, + "type_info": "Datetime" + }, + { + "name": "opened_at", + "ordinal": 11, + "type_info": "Datetime" + }, + { + "name": "address", + "ordinal": 12, + "type_info": "Text" + } + ], + "parameters": { + "Right": 4 + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + true, + false, + true, + true, + false + ] + }, + "hash": "904ebe16d0f2c64b9e640f2724d84cd930cf530ae70f29c677a26d79d7828684" +} diff --git a/.sqlx/query-9469ed4a1d01fbe173d5693668b3f908bbebbf138f7e95d07c196b17c176d83d.json b/.sqlx/query-9469ed4a1d01fbe173d5693668b3f908bbebbf138f7e95d07c196b17c176d83d.json new file mode 100644 index 00000000..4a3c1475 --- /dev/null +++ b/.sqlx/query-9469ed4a1d01fbe173d5693668b3f908bbebbf138f7e95d07c196b17c176d83d.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n l.name AS name,\n COUNT(lm.user_id) AS count,\n SUM(\n CASE WHEN EXISTS (\n SELECT 1\n FROM emails e\n WHERE kind = ?\n AND e.user_id = u.id\n AND e.event_id = ?\n AND e.sent_at IS NOT NULL\n )\n THEN 1 ELSE 0 END\n ) AS sent\n FROM lists l\n LEFT JOIN list_members lm ON lm.list_id = l.id\n LEFT JOIN users u ON u.id = lm.user_id\n WHERE l.id = ?\n GROUP BY l.id;\n ", + "describe": { + "columns": [ + { + "name": "name", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "count", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "sent", + "ordinal": 2, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "9469ed4a1d01fbe173d5693668b3f908bbebbf138f7e95d07c196b17c176d83d" +} diff --git a/.sqlx/query-9546f551138edd30d2c27560d55c41d13da40b3e927ab0eca381ab3af2b81455.json b/.sqlx/query-9546f551138edd30d2c27560d55c41d13da40b3e927ab0eca381ab3af2b81455.json new file mode 100644 index 00000000..7e2c5250 --- /dev/null +++ b/.sqlx/query-9546f551138edd30d2c27560d55c41d13da40b3e927ab0eca381ab3af2b81455.json @@ -0,0 +1,164 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM events WHERE id = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "title", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "slug", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "start", + "ordinal": 3, + "type_info": "Datetime" + }, + { + "name": "end", + "ordinal": 4, + "type_info": "Datetime" + }, + { + "name": "capacity", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "unlisted", + "ordinal": 6, + "type_info": "Bool" + }, + { + "name": "closed", + "ordinal": 7, + "type_info": "Bool" + }, + { + "name": "guest_list_id", + "ordinal": 8, + "type_info": "Integer" + }, + { + "name": "spots_per_person", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "description_html", + "ordinal": 10, + "type_info": "Text" + }, + { + "name": "description_updated_at", + "ordinal": 11, + "type_info": "Datetime" + }, + { + "name": "invite_subject", + "ordinal": 12, + "type_info": "Text" + }, + { + "name": "invite_html", + "ordinal": 13, + "type_info": "Text" + }, + { + "name": "invite_updated_at", + "ordinal": 14, + "type_info": "Datetime" + }, + { + "name": "invite_sent_at", + "ordinal": 15, + "type_info": "Datetime" + }, + { + "name": "confirmation_subject", + "ordinal": 16, + "type_info": "Text" + }, + { + "name": "confirmation_html", + "ordinal": 17, + "type_info": "Text" + }, + { + "name": "confirmation_updated_at", + "ordinal": 18, + "type_info": "Datetime" + }, + { + "name": "dayof_subject", + "ordinal": 19, + "type_info": "Text" + }, + { + "name": "dayof_html", + "ordinal": 20, + "type_info": "Text" + }, + { + "name": "dayof_updated_at", + "ordinal": 21, + "type_info": "Datetime" + }, + { + "name": "dayof_sent_at", + "ordinal": 22, + "type_info": "Datetime" + }, + { + "name": "created_at", + "ordinal": 23, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 24, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false + ] + }, + "hash": "9546f551138edd30d2c27560d55c41d13da40b3e927ab0eca381ab3af2b81455" +} diff --git a/.sqlx/query-964a241138128189d33b7eae8984d60777cf278c40214dcb62b6e9c1bb016675.json b/.sqlx/query-964a241138128189d33b7eae8984d60777cf278c40214dcb62b6e9c1bb016675.json new file mode 100644 index 00000000..74694359 --- /dev/null +++ b/.sqlx/query-964a241138128189d33b7eae8984d60777cf278c40214dcb62b6e9c1bb016675.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE lists\n SET name = ?, description = ?\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "964a241138128189d33b7eae8984d60777cf278c40214dcb62b6e9c1bb016675" +} diff --git a/.sqlx/query-9943a87d5473a35a911c577fb084c9bc38c9a81a131ce6bf2b979d8a5b10c79a.json b/.sqlx/query-9943a87d5473a35a911c577fb084c9bc38c9a81a131ce6bf2b979d8a5b10c79a.json new file mode 100644 index 00000000..4b61d07e --- /dev/null +++ b/.sqlx/query-9943a87d5473a35a911c577fb084c9bc38c9a81a131ce6bf2b979d8a5b10c79a.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM manual_rsvps WHERE event_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "9943a87d5473a35a911c577fb084c9bc38c9a81a131ce6bf2b979d8a5b10c79a" +} diff --git a/.sqlx/query-9bd49efdcc802c3cb34da479563703d7bd55f9e17e05d970221b9329789045fa.json b/.sqlx/query-9bd49efdcc802c3cb34da479563703d7bd55f9e17e05d970221b9329789045fa.json new file mode 100644 index 00000000..b3fb4ac2 --- /dev/null +++ b/.sqlx/query-9bd49efdcc802c3cb34da479563703d7bd55f9e17e05d970221b9329789045fa.json @@ -0,0 +1,92 @@ +{ + "db_name": "SQLite", + "query": "SELECT e.*, u.email as address FROM emails e\n JOIN users u ON u.id = e.user_id\n WHERE e.id = ?\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "kind", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "user_version", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "post_id", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "list_id", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "event_id", + "ordinal": 6, + "type_info": "Integer" + }, + { + "name": "notification_id", + "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "error", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 9, + "type_info": "Datetime" + }, + { + "name": "sent_at", + "ordinal": 10, + "type_info": "Datetime" + }, + { + "name": "opened_at", + "ordinal": 11, + "type_info": "Datetime" + }, + { + "name": "address", + "ordinal": 12, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + true, + false, + true, + true, + false + ] + }, + "hash": "9bd49efdcc802c3cb34da479563703d7bd55f9e17e05d970221b9329789045fa" +} diff --git a/.sqlx/query-9c534cdec87dec71efe8ab59329e6f85f32411793dd3e618dd830a8365159a94.json b/.sqlx/query-9c534cdec87dec71efe8ab59329e6f85f32411793dd3e618dd830a8365159a94.json new file mode 100644 index 00000000..0e6e7132 --- /dev/null +++ b/.sqlx/query-9c534cdec87dec71efe8ab59329e6f85f32411793dd3e618dd830a8365159a94.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE emails SET sent_at = CURRENT_TIMESTAMP\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "9c534cdec87dec71efe8ab59329e6f85f32411793dd3e618dd830a8365159a94" +} diff --git a/.sqlx/query-a3f2a593f8de75e0f898035d3004d0e37c7d66fdf2f878fd9ad0bb2ac8e1e1fc.json b/.sqlx/query-a3f2a593f8de75e0f898035d3004d0e37c7d66fdf2f878fd9ad0bb2ac8e1e1fc.json new file mode 100644 index 00000000..556be868 --- /dev/null +++ b/.sqlx/query-a3f2a593f8de75e0f898035d3004d0e37c7d66fdf2f878fd9ad0bb2ac8e1e1fc.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "UPDATE EVENTS\n SET invite_subject = ?,\n invite_html = ?,\n invite_updated_at = CURRENT_TIMESTAMP,\n updated_at = CURRENT_TIMESTAMP\n WHERE id = ?\n RETURNING invite_updated_at", + "describe": { + "columns": [ + { + "name": "invite_updated_at", + "ordinal": 0, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + true + ] + }, + "hash": "a3f2a593f8de75e0f898035d3004d0e37c7d66fdf2f878fd9ad0bb2ac8e1e1fc" +} diff --git a/.sqlx/query-a76250e7f015d820a55e1072fff9ea988ee03b8b2dd115156e574972d37361bf.json b/.sqlx/query-a76250e7f015d820a55e1072fff9ea988ee03b8b2dd115156e574972d37361bf.json new file mode 100644 index 00000000..e694cb34 --- /dev/null +++ b/.sqlx/query-a76250e7f015d820a55e1072fff9ea988ee03b8b2dd115156e574972d37361bf.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "UPDATE EVENTS\n SET confirmation_subject = ?,\n confirmation_html = ?,\n confirmation_updated_at = CURRENT_TIMESTAMP,\n updated_at = CURRENT_TIMESTAMP\n WHERE id = ?\n RETURNING confirmation_updated_at", + "describe": { + "columns": [ + { + "name": "confirmation_updated_at", + "ordinal": 0, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + true + ] + }, + "hash": "a76250e7f015d820a55e1072fff9ea988ee03b8b2dd115156e574972d37361bf" +} diff --git a/.sqlx/query-ad98570b892a1f6b2d6ee1489ec0a8755aa3191ec595d6f1f5912e136857fd45.json b/.sqlx/query-ad98570b892a1f6b2d6ee1489ec0a8755aa3191ec595d6f1f5912e136857fd45.json new file mode 100644 index 00000000..67fb0cbb --- /dev/null +++ b/.sqlx/query-ad98570b892a1f6b2d6ee1489ec0a8755aa3191ec595d6f1f5912e136857fd45.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO rsvps\n (session_id, spot_id, contribution, user_id, user_version)\n VALUES (?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "ad98570b892a1f6b2d6ee1489ec0a8755aa3191ec595d6f1f5912e136857fd45" +} diff --git a/.sqlx/query-b6f12540d668aa08bcd3cc1eb36eddfbeeb5b72fcfce3520fabae8cf3d4e873d.json b/.sqlx/query-b6f12540d668aa08bcd3cc1eb36eddfbeeb5b72fcfce3520fabae8cf3d4e873d.json new file mode 100644 index 00000000..09b142d5 --- /dev/null +++ b/.sqlx/query-b6f12540d668aa08bcd3cc1eb36eddfbeeb5b72fcfce3520fabae8cf3d4e873d.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO login_tokens (user_id, token) VALUES (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "b6f12540d668aa08bcd3cc1eb36eddfbeeb5b72fcfce3520fabae8cf3d4e873d" +} diff --git a/.sqlx/query-ba023e2eb9e9d2cef3ef523fcbb3011dd8331929b30236a5b138003f4e72af22.json b/.sqlx/query-ba023e2eb9e9d2cef3ef523fcbb3011dd8331929b30236a5b138003f4e72af22.json new file mode 100644 index 00000000..8f44e78a --- /dev/null +++ b/.sqlx/query-ba023e2eb9e9d2cef3ef523fcbb3011dd8331929b30236a5b138003f4e72af22.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT EXISTS (\n SELECT 1\n FROM list_members lm\n LEFT JOIN users u ON u.id = lm.user_id\n WHERE lm.list_id = ?\n AND u.email = ? COLLATE NOCASE\n ) AS \"exists!: bool\"\n ", + "describe": { + "columns": [ + { + "name": "exists!: bool", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false + ] + }, + "hash": "ba023e2eb9e9d2cef3ef523fcbb3011dd8331929b30236a5b138003f4e72af22" +} diff --git a/.sqlx/query-be5a9a8cde335e8dfef0bfd08ebb30818dc76a8e5d09ad9dc876ccb293fe32db.json b/.sqlx/query-be5a9a8cde335e8dfef0bfd08ebb30818dc76a8e5d09ad9dc876ccb293fe32db.json new file mode 100644 index 00000000..997b9327 --- /dev/null +++ b/.sqlx/query-be5a9a8cde335e8dfef0bfd08ebb30818dc76a8e5d09ad9dc876ccb293fe32db.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT COUNT(*) FROM flyers", + "describe": { + "columns": [ + { + "name": "COUNT(*)", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false + ] + }, + "hash": "be5a9a8cde335e8dfef0bfd08ebb30818dc76a8e5d09ad9dc876ccb293fe32db" +} diff --git a/.sqlx/query-beae8fe5b9443d004e595b7240d20ac75666b604db733e5b2b95ed027ea158b5.json b/.sqlx/query-beae8fe5b9443d004e595b7240d20ac75666b604db733e5b2b95ed027ea158b5.json new file mode 100644 index 00000000..0b9f9067 --- /dev/null +++ b/.sqlx/query-beae8fe5b9443d004e595b7240d20ac75666b604db733e5b2b95ed027ea158b5.json @@ -0,0 +1,86 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM rsvp_sessions WHERE id = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "event_id", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "token", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "status", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "user_version", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "stripe_client_secret", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "stripe_payment_intent_id", + "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "stripe_charge_id", + "ordinal": 8, + "type_info": "Integer" + }, + { + "name": "stripe_refund_id", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "created_at", + "ordinal": 10, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 11, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + true, + true, + false, + false + ] + }, + "hash": "beae8fe5b9443d004e595b7240d20ac75666b604db733e5b2b95ed027ea158b5" +} diff --git a/.sqlx/query-c0c119524815d33dd5e781f3e525c248b47e6b311211b3cd74ce45db486e77cc.json b/.sqlx/query-c0c119524815d33dd5e781f3e525c248b47e6b311211b3cd74ce45db486e77cc.json new file mode 100644 index 00000000..7221c0ce --- /dev/null +++ b/.sqlx/query-c0c119524815d33dd5e781f3e525c248b47e6b311211b3cd74ce45db486e77cc.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "SELECT\n r.id AS rsvp_id,\n r.user_id,\n s.name AS spot_name,\n u.first_name,\n u.last_name,\n u.email,\n u.phone,\n r.contribution\n FROM rsvps r\n JOIN spots s ON s.id = r.spot_id\n JOIN rsvp_sessions rs ON rs.id = r.session_id\n LEFT JOIN users u ON u.id = r.user_id\n WHERE rs.id = ?\n ", + "describe": { + "columns": [ + { + "name": "rsvp_id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "user_id", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "spot_name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "first_name", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "last_name", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "email", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "phone", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "contribution", + "ordinal": 7, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true, + false, + true, + true, + true, + true, + false + ] + }, + "hash": "c0c119524815d33dd5e781f3e525c248b47e6b311211b3cd74ce45db486e77cc" +} diff --git a/.sqlx/query-c13da30f24cf0fe24e2c626145befedc956e1bb1fdb72c20ac027cfa2c376a9b.json b/.sqlx/query-c13da30f24cf0fe24e2c626145befedc956e1bb1fdb72c20ac027cfa2c376a9b.json new file mode 100644 index 00000000..5b826f18 --- /dev/null +++ b/.sqlx/query-c13da30f24cf0fe24e2c626145befedc956e1bb1fdb72c20ac027cfa2c376a9b.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE flyers SET link_url = ?, flyer_name = ? WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "c13da30f24cf0fe24e2c626145befedc956e1bb1fdb72c20ac027cfa2c376a9b" +} diff --git a/.sqlx/query-c1773bd8f5153e103ba44e8f23fd6fa3bd54ef77809d015016f56840264de823.json b/.sqlx/query-c1773bd8f5153e103ba44e8f23fd6fa3bd54ef77809d015016f56840264de823.json new file mode 100644 index 00000000..0ee8eccb --- /dev/null +++ b/.sqlx/query-c1773bd8f5153e103ba44e8f23fd6fa3bd54ef77809d015016f56840264de823.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT COUNT(*) as count FROM event_flyers WHERE event_id = ?", + "describe": { + "columns": [ + { + "name": "count", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "c1773bd8f5153e103ba44e8f23fd6fa3bd54ef77809d015016f56840264de823" +} diff --git a/.sqlx/query-c1ae6350c894b164cc33d749e58f9a9039f5eb80670128460f430716768856cd.json b/.sqlx/query-c1ae6350c894b164cc33d749e58f9a9039f5eb80670128460f430716768856cd.json new file mode 100644 index 00000000..028a399f --- /dev/null +++ b/.sqlx/query-c1ae6350c894b164cc33d749e58f9a9039f5eb80670128460f430716768856cd.json @@ -0,0 +1,164 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM events\n WHERE start > DATETIME(CURRENT_TIMESTAMP, '-24 hours')\n AND unlisted = FALSE\n ORDER BY start ASC", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "title", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "slug", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "start", + "ordinal": 3, + "type_info": "Datetime" + }, + { + "name": "end", + "ordinal": 4, + "type_info": "Datetime" + }, + { + "name": "capacity", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "unlisted", + "ordinal": 6, + "type_info": "Bool" + }, + { + "name": "closed", + "ordinal": 7, + "type_info": "Bool" + }, + { + "name": "guest_list_id", + "ordinal": 8, + "type_info": "Integer" + }, + { + "name": "spots_per_person", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "description_html", + "ordinal": 10, + "type_info": "Text" + }, + { + "name": "description_updated_at", + "ordinal": 11, + "type_info": "Datetime" + }, + { + "name": "invite_subject", + "ordinal": 12, + "type_info": "Text" + }, + { + "name": "invite_html", + "ordinal": 13, + "type_info": "Text" + }, + { + "name": "invite_updated_at", + "ordinal": 14, + "type_info": "Datetime" + }, + { + "name": "invite_sent_at", + "ordinal": 15, + "type_info": "Datetime" + }, + { + "name": "confirmation_subject", + "ordinal": 16, + "type_info": "Text" + }, + { + "name": "confirmation_html", + "ordinal": 17, + "type_info": "Text" + }, + { + "name": "confirmation_updated_at", + "ordinal": 18, + "type_info": "Datetime" + }, + { + "name": "dayof_subject", + "ordinal": 19, + "type_info": "Text" + }, + { + "name": "dayof_html", + "ordinal": 20, + "type_info": "Text" + }, + { + "name": "dayof_updated_at", + "ordinal": 21, + "type_info": "Datetime" + }, + { + "name": "dayof_sent_at", + "ordinal": 22, + "type_info": "Datetime" + }, + { + "name": "created_at", + "ordinal": 23, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 24, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false + ] + }, + "hash": "c1ae6350c894b164cc33d749e58f9a9039f5eb80670128460f430716768856cd" +} diff --git a/.sqlx/query-c71a7adfa00e36bc16c5a66dd1f3b367c4a44396f96ddf14c09a5784f4eabd7a.json b/.sqlx/query-c71a7adfa00e36bc16c5a66dd1f3b367c4a44396f96ddf14c09a5784f4eabd7a.json new file mode 100644 index 00000000..ad0437be --- /dev/null +++ b/.sqlx/query-c71a7adfa00e36bc16c5a66dd1f3b367c4a44396f96ddf14c09a5784f4eabd7a.json @@ -0,0 +1,68 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n u.*,\n COALESCE(MAX(h.version), 0) as \"version!: i64\",\n COALESCE(GROUP_CONCAT(r.role), '') AS \"roles!: String\"\n FROM users u\n LEFT JOIN session_tokens t ON t.user_id = u.id\n JOIN user_history h ON h.user_id = u.id\n LEFT JOIN user_roles r ON r.user_id = u.id\n WHERE t.token = ?\n GROUP BY u.id\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "email", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "first_name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "last_name", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "phone", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 5, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 6, + "type_info": "Datetime" + }, + { + "name": "version!: i64", + "ordinal": 7, + "type_info": "Null" + }, + { + "name": "roles!: String", + "ordinal": 8, + "type_info": "Null" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + true, + true, + true, + false, + false, + null, + null + ] + }, + "hash": "c71a7adfa00e36bc16c5a66dd1f3b367c4a44396f96ddf14c09a5784f4eabd7a" +} diff --git a/.sqlx/query-c8dc6efb3752aa3aa77c9c0a5cacedff3c6f96555823eaac4864febc8d18cf26.json b/.sqlx/query-c8dc6efb3752aa3aa77c9c0a5cacedff3c6f96555823eaac4864febc8d18cf26.json new file mode 100644 index 00000000..ec6f37a9 --- /dev/null +++ b/.sqlx/query-c8dc6efb3752aa3aa77c9c0a5cacedff3c6f96555823eaac4864febc8d18cf26.json @@ -0,0 +1,68 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO users\n (email, first_name, last_name, phone)\n VALUES (?, ?, ?, ?)\n RETURNING *, 0 as version, '' as roles\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "email", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "first_name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "last_name", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "phone", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 5, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 6, + "type_info": "Datetime" + }, + { + "name": "version", + "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "roles", + "ordinal": 8, + "type_info": "Text" + } + ], + "parameters": { + "Right": 4 + }, + "nullable": [ + false, + false, + true, + true, + true, + false, + false, + false, + false + ] + }, + "hash": "c8dc6efb3752aa3aa77c9c0a5cacedff3c6f96555823eaac4864febc8d18cf26" +} diff --git a/.sqlx/query-c95887b134b158250adb9348771682167708e107320427612ac83d4cbe03b825.json b/.sqlx/query-c95887b134b158250adb9348771682167708e107320427612ac83d4cbe03b825.json new file mode 100644 index 00000000..d3f71cb5 --- /dev/null +++ b/.sqlx/query-c95887b134b158250adb9348771682167708e107320427612ac83d4cbe03b825.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT x, y FROM flyers WHERE id = ?", + "describe": { + "columns": [ + { + "name": "x", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "y", + "ordinal": 1, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false + ] + }, + "hash": "c95887b134b158250adb9348771682167708e107320427612ac83d4cbe03b825" +} diff --git a/.sqlx/query-cb8ebe3dbe5eb6bcb86522afa953d96c436371c4aee43fd916de2f72198bffd3.json b/.sqlx/query-cb8ebe3dbe5eb6bcb86522afa953d96c436371c4aee43fd916de2f72198bffd3.json new file mode 100644 index 00000000..fa5e8756 --- /dev/null +++ b/.sqlx/query-cb8ebe3dbe5eb6bcb86522afa953d96c436371c4aee43fd916de2f72198bffd3.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE rsvp_sessions\n SET stripe_client_secret = ?, updated_at = CURRENT_TIMESTAMP\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "cb8ebe3dbe5eb6bcb86522afa953d96c436371c4aee43fd916de2f72198bffd3" +} diff --git a/.sqlx/query-cc37470ea4e4e8198253e29291b3174372b5a7b561ffbdc5cc6c1c08e7bcb38e.json b/.sqlx/query-cc37470ea4e4e8198253e29291b3174372b5a7b561ffbdc5cc6c1c08e7bcb38e.json new file mode 100644 index 00000000..76206a7d --- /dev/null +++ b/.sqlx/query-cc37470ea4e4e8198253e29291b3174372b5a7b561ffbdc5cc6c1c08e7bcb38e.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM rsvps AS r\n WHERE NOT EXISTS (\n SELECT 1 FROM rsvp_sessions s\n WHERE s.id = r.session_id\n )", + "describe": { + "columns": [], + "parameters": { + "Right": 0 + }, + "nullable": [] + }, + "hash": "cc37470ea4e4e8198253e29291b3174372b5a7b561ffbdc5cc6c1c08e7bcb38e" +} diff --git a/.sqlx/query-ce644c8de2b423f5e8c9079ea7eb3175f5dea2d8b7c407b17a306e5166aab3e4.json b/.sqlx/query-ce644c8de2b423f5e8c9079ea7eb3175f5dea2d8b7c407b17a306e5166aab3e4.json new file mode 100644 index 00000000..6437e012 --- /dev/null +++ b/.sqlx/query-ce644c8de2b423f5e8c9079ea7eb3175f5dea2d8b7c407b17a306e5166aab3e4.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM lists", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 3, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 4, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "ce644c8de2b423f5e8c9079ea7eb3175f5dea2d8b7c407b17a306e5166aab3e4" +} diff --git a/.sqlx/query-d2f045b2973edcc1a81525e089999886861b054753515afcb454245d7cd1d498.json b/.sqlx/query-d2f045b2973edcc1a81525e089999886861b054753515afcb454245d7cd1d498.json new file mode 100644 index 00000000..7a752d26 --- /dev/null +++ b/.sqlx/query-d2f045b2973edcc1a81525e089999886861b054753515afcb454245d7cd1d498.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "UPDATE posts\n SET title = ?,\n slug = ?,\n author = ?,\n content = ?,\n updated_at = CURRENT_TIMESTAMP\n WHERE id = ?\n RETURNING id, updated_at", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "updated_at", + "ordinal": 1, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 5 + }, + "nullable": [ + false, + false + ] + }, + "hash": "d2f045b2973edcc1a81525e089999886861b054753515afcb454245d7cd1d498" +} diff --git a/.sqlx/query-d364c3f63f83bae9234419434181abec8e5cc21ec02181b490da305a8399cb5a.json b/.sqlx/query-d364c3f63f83bae9234419434181abec8e5cc21ec02181b490da305a8399cb5a.json new file mode 100644 index 00000000..cfb0afc0 --- /dev/null +++ b/.sqlx/query-d364c3f63f83bae9234419434181abec8e5cc21ec02181b490da305a8399cb5a.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE manual_rsvps SET checkin_at = NULL WHERE event_id = ? AND user_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "d364c3f63f83bae9234419434181abec8e5cc21ec02181b490da305a8399cb5a" +} diff --git a/.sqlx/query-d47a2dd0b5204a2615c1587103bef49de18f6ed80b4d8ed858aeb9dbf0927cf1.json b/.sqlx/query-d47a2dd0b5204a2615c1587103bef49de18f6ed80b4d8ed858aeb9dbf0927cf1.json new file mode 100644 index 00000000..3e026d39 --- /dev/null +++ b/.sqlx/query-d47a2dd0b5204a2615c1587103bef49de18f6ed80b4d8ed858aeb9dbf0927cf1.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO events\n (title, slug, start, end, capacity, unlisted, closed, guest_list_id, spots_per_person)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 9 + }, + "nullable": [] + }, + "hash": "d47a2dd0b5204a2615c1587103bef49de18f6ed80b4d8ed858aeb9dbf0927cf1" +} diff --git a/.sqlx/query-d4aa81016180e5199aaacb7bc612edf58480c03c3b0a439f9e335a810cf122b9.json b/.sqlx/query-d4aa81016180e5199aaacb7bc612edf58480c03c3b0a439f9e335a810cf122b9.json new file mode 100644 index 00000000..aa0b8e8d --- /dev/null +++ b/.sqlx/query-d4aa81016180e5199aaacb7bc612edf58480c03c3b0a439f9e335a810cf122b9.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO events\n (title, slug, start, end, capacity, unlisted, closed, guest_list_id, spots_per_person,\n description_html, description_updated_at,\n invite_subject, invite_html, invite_updated_at,\n confirmation_subject, confirmation_html, confirmation_updated_at,\n dayof_subject, dayof_html, dayof_updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?,\n ?, ?,\n ?, ?, ?,\n ?, ?, ?,\n ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 20 + }, + "nullable": [] + }, + "hash": "d4aa81016180e5199aaacb7bc612edf58480c03c3b0a439f9e335a810cf122b9" +} diff --git a/.sqlx/query-d51c3184a3f1ef6b200f59eaf20ff3837a7d1e8b0ced59d25b38f78348a8a12f.json b/.sqlx/query-d51c3184a3f1ef6b200f59eaf20ff3837a7d1e8b0ced59d25b38f78348a8a12f.json new file mode 100644 index 00000000..eb555b5c --- /dev/null +++ b/.sqlx/query-d51c3184a3f1ef6b200f59eaf20ff3837a7d1e8b0ced59d25b38f78348a8a12f.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE notifications\n SET name = ?, content = ?\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "d51c3184a3f1ef6b200f59eaf20ff3837a7d1e8b0ced59d25b38f78348a8a12f" +} diff --git a/.sqlx/query-d5ed71da563d1e1ec855dc3043100b349da6f365d7c011616ee37d551169e3cc.json b/.sqlx/query-d5ed71da563d1e1ec855dc3043100b349da6f365d7c011616ee37d551169e3cc.json new file mode 100644 index 00000000..4232f6de --- /dev/null +++ b/.sqlx/query-d5ed71da563d1e1ec855dc3043100b349da6f365d7c011616ee37d551169e3cc.json @@ -0,0 +1,86 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM rsvp_sessions WHERE token = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "event_id", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "token", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "status", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "user_version", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "stripe_client_secret", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "stripe_payment_intent_id", + "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "stripe_charge_id", + "ordinal": 8, + "type_info": "Integer" + }, + { + "name": "stripe_refund_id", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "created_at", + "ordinal": 10, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 11, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + true, + true, + false, + false + ] + }, + "hash": "d5ed71da563d1e1ec855dc3043100b349da6f365d7c011616ee37d551169e3cc" +} diff --git a/.sqlx/query-d6181356351b8265ac4723ffc9c8d12e8f8b8d4199a9d6815ce0d7f62e2b5728.json b/.sqlx/query-d6181356351b8265ac4723ffc9c8d12e8f8b8d4199a9d6815ce0d7f62e2b5728.json new file mode 100644 index 00000000..ab301cbc --- /dev/null +++ b/.sqlx/query-d6181356351b8265ac4723ffc9c8d12e8f8b8d4199a9d6815ce0d7f62e2b5728.json @@ -0,0 +1,92 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO emails (kind, user_id, user_version, post_id, list_id)\n SELECT ?, u.id, uh.version, ?, lm.list_id\n FROM list_members lm\n JOIN users u ON u.id = lm.user_id\n JOIN user_history uh ON uh.user_id = u.id\n WHERE lm.list_id = ?\n AND NOT EXISTS (\n SELECT 1\n FROM emails e\n WHERE e.user_id = u.id\n AND e.post_id = ?\n AND e.list_id = lm.list_id\n AND e.sent_at IS NULL\n )\n RETURNING *, (\n SELECT u.email FROM users u\n WHERE u.id = emails.user_id\n ) as address\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "kind", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "user_version", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "post_id", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "list_id", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "event_id", + "ordinal": 6, + "type_info": "Integer" + }, + { + "name": "notification_id", + "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "error", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 9, + "type_info": "Datetime" + }, + { + "name": "sent_at", + "ordinal": 10, + "type_info": "Datetime" + }, + { + "name": "opened_at", + "ordinal": 11, + "type_info": "Datetime" + }, + { + "name": "address", + "ordinal": 12, + "type_info": "Text" + } + ], + "parameters": { + "Right": 4 + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + true, + false, + true, + true, + false + ] + }, + "hash": "d6181356351b8265ac4723ffc9c8d12e8f8b8d4199a9d6815ce0d7f62e2b5728" +} diff --git a/.sqlx/query-d6ace25a7ffcc03ee6a1b199371369ac0ecba36a7abd4470666ed6a77d3bac42.json b/.sqlx/query-d6ace25a7ffcc03ee6a1b199371369ac0ecba36a7abd4470666ed6a77d3bac42.json new file mode 100644 index 00000000..059abcfa --- /dev/null +++ b/.sqlx/query-d6ace25a7ffcc03ee6a1b199371369ac0ecba36a7abd4470666ed6a77d3bac42.json @@ -0,0 +1,92 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT e.*, u.email as address FROM emails e\n JOIN users u ON u.id = e.user_id\n WHERE e.kind = ? AND e.event_id = ? AND e.list_id = ?\n AND ifnull(e.sent_at, '') = (\n SELECT ifnull(MAX(ee.sent_at), '')\n FROM emails ee\n WHERE ee.kind = e.kind\n AND ee.list_id = e.list_id\n AND ee.user_id = e.user_id\n AND ee.event_id = e.event_id\n );\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "kind", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "user_version", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "post_id", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "list_id", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "event_id", + "ordinal": 6, + "type_info": "Integer" + }, + { + "name": "notification_id", + "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "error", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 9, + "type_info": "Datetime" + }, + { + "name": "sent_at", + "ordinal": 10, + "type_info": "Datetime" + }, + { + "name": "opened_at", + "ordinal": 11, + "type_info": "Datetime" + }, + { + "name": "address", + "ordinal": 12, + "type_info": "Text" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + true, + false, + true, + true, + false + ] + }, + "hash": "d6ace25a7ffcc03ee6a1b199371369ac0ecba36a7abd4470666ed6a77d3bac42" +} diff --git a/.sqlx/query-d84dae7ff71630da062b1c96d07a635b8c5f8e010a46fe63802a03f385977c7d.json b/.sqlx/query-d84dae7ff71630da062b1c96d07a635b8c5f8e010a46fe63802a03f385977c7d.json new file mode 100644 index 00000000..c72c2be6 --- /dev/null +++ b/.sqlx/query-d84dae7ff71630da062b1c96d07a635b8c5f8e010a46fe63802a03f385977c7d.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT id FROM emails WHERE kind = ? AND event_id = ? AND user_id = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false + ] + }, + "hash": "d84dae7ff71630da062b1c96d07a635b8c5f8e010a46fe63802a03f385977c7d" +} diff --git a/.sqlx/query-d858e7b4282e263df3d1c2406989bbf885f3bc0809a118643500455311343b0c.json b/.sqlx/query-d858e7b4282e263df3d1c2406989bbf885f3bc0809a118643500455311343b0c.json new file mode 100644 index 00000000..bb0e60e3 --- /dev/null +++ b/.sqlx/query-d858e7b4282e263df3d1c2406989bbf885f3bc0809a118643500455311343b0c.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM manual_rsvps WHERE event_id = ? AND user_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "d858e7b4282e263df3d1c2406989bbf885f3bc0809a118643500455311343b0c" +} diff --git a/.sqlx/query-dd2f2b72dfb17f591fc00c87d75c5994d885440678689efcd6103a70ac22e047.json b/.sqlx/query-dd2f2b72dfb17f591fc00c87d75c5994d885440678689efcd6103a70ac22e047.json new file mode 100644 index 00000000..e2e03b26 --- /dev/null +++ b/.sqlx/query-dd2f2b72dfb17f591fc00c87d75c5994d885440678689efcd6103a70ac22e047.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n u.id AS user_id,\n u.first_name as \"first_name!\",\n u.last_name as \"last_name!\",\n u.email,\n CASE\n WHEN rs.user_id IS NOT NULL AND rs.user_id != r.user_id\n THEN hu.first_name || ' ' || hu.last_name\n ELSE NULL\n END AS guest_of,\n\n sp.name AS spot_name,\n r.contribution,\n\n FALSE AS \"is_manual!: bool\",\n r.created_at,\n r.checkin_at\n FROM rsvps r\n JOIN rsvp_sessions rs ON rs.id = r.session_id\n JOIN spots sp ON sp.id = r.spot_id\n JOIN users u ON u.id = r.user_id\n JOIN users hu ON hu.id = rs.user_id\n WHERE rs.event_id = ?\n AND rs.status IN ('payment_pending', 'payment_confirmed')\n\n UNION ALL\n\n SELECT\n u.id AS user_id,\n u.first_name as \"first_name!\",\n u.last_name as \"last_name!\",\n u.email,\n cu.first_name || ' ' || cu.last_name AS guest_of,\n\n NULL AS spot_name,\n 0 AS contribution,\n\n TRUE AS \"is_manual!: bool\",\n mr.created_at,\n mr.checkin_at\n FROM manual_rsvps mr\n JOIN users u ON u.id = mr.user_id\n JOIN users cu ON cu.id = mr.creator_user_id\n WHERE mr.event_id = ?\n\n ORDER BY 9;\n ", + "describe": { + "columns": [ + { + "name": "user_id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "first_name!", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "last_name!", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "email", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "guest_of", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "spot_name", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "contribution", + "ordinal": 6, + "type_info": "Integer" + }, + { + "name": "is_manual!: bool", + "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "created_at", + "ordinal": 8, + "type_info": "Datetime" + }, + { + "name": "checkin_at", + "ordinal": 9, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + true, + true, + false, + true, + false, + false, + false, + false, + true + ] + }, + "hash": "dd2f2b72dfb17f591fc00c87d75c5994d885440678689efcd6103a70ac22e047" +} diff --git a/.sqlx/query-deffde74de2e6e1200920eae15748ce7899becbdcbf6b8dad96bbcd691acb3fc.json b/.sqlx/query-deffde74de2e6e1200920eae15748ce7899becbdcbf6b8dad96bbcd691acb3fc.json new file mode 100644 index 00000000..5496225e --- /dev/null +++ b/.sqlx/query-deffde74de2e6e1200920eae15748ce7899becbdcbf6b8dad96bbcd691acb3fc.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE rsvp_sessions\n SET user_id = ?,\n user_version = ?,\n updated_at = CURRENT_TIMESTAMP\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "deffde74de2e6e1200920eae15748ce7899becbdcbf6b8dad96bbcd691acb3fc" +} diff --git a/.sqlx/query-df6e050c7cf06ac944f01151a07584d3ddc0c6b432b7c518cb2a8140e9a08613.json b/.sqlx/query-df6e050c7cf06ac944f01151a07584d3ddc0c6b432b7c518cb2a8140e9a08613.json new file mode 100644 index 00000000..0f22d125 --- /dev/null +++ b/.sqlx/query-df6e050c7cf06ac944f01151a07584d3ddc0c6b432b7c518cb2a8140e9a08613.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM posts ORDER BY updated_at DESC", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "title", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "slug", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "author", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "content", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 5, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 6, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "df6e050c7cf06ac944f01151a07584d3ddc0c6b432b7c518cb2a8140e9a08613" +} diff --git a/.sqlx/query-e2352164237b90647e57f3d212ee24456e16e5a12e033a0f6ce315c9ce838a4e.json b/.sqlx/query-e2352164237b90647e57f3d212ee24456e16e5a12e033a0f6ce315c9ce838a4e.json new file mode 100644 index 00000000..10c970d6 --- /dev/null +++ b/.sqlx/query-e2352164237b90647e57f3d212ee24456e16e5a12e033a0f6ce315c9ce838a4e.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM events WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "e2352164237b90647e57f3d212ee24456e16e5a12e033a0f6ce315c9ce838a4e" +} diff --git a/.sqlx/query-e74c7b012c77c20c04d3a57bba0232993b4164eb538dbc1dcb2ba0953086d46d.json b/.sqlx/query-e74c7b012c77c20c04d3a57bba0232993b4164eb538dbc1dcb2ba0953086d46d.json new file mode 100644 index 00000000..c4ecbf8e --- /dev/null +++ b/.sqlx/query-e74c7b012c77c20c04d3a57bba0232993b4164eb538dbc1dcb2ba0953086d46d.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM rsvp_sessions\n WHERE status in (?, ?, ?)\n AND updated_at < datetime('now', ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "e74c7b012c77c20c04d3a57bba0232993b4164eb538dbc1dcb2ba0953086d46d" +} diff --git a/.sqlx/query-e8dd518fdf1f27e5bddb3f65ca4ae844e133f8c89919e705b945df6480c92044.json b/.sqlx/query-e8dd518fdf1f27e5bddb3f65ca4ae844e133f8c89919e705b945df6480c92044.json new file mode 100644 index 00000000..ff45d58d --- /dev/null +++ b/.sqlx/query-e8dd518fdf1f27e5bddb3f65ca4ae844e133f8c89919e705b945df6480c92044.json @@ -0,0 +1,86 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO rsvp_sessions\n (event_id, token, status, user_id, user_version)\n VALUES (?, ?, ?, ?, ?)\n RETURNING *", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "event_id", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "token", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "status", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "user_version", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "stripe_client_secret", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "stripe_payment_intent_id", + "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "stripe_charge_id", + "ordinal": 8, + "type_info": "Integer" + }, + { + "name": "stripe_refund_id", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "created_at", + "ordinal": 10, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 11, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 5 + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + true, + true, + false, + false + ] + }, + "hash": "e8dd518fdf1f27e5bddb3f65ca4ae844e133f8c89919e705b945df6480c92044" +} diff --git a/.sqlx/query-ea25cd3247cbe31212015ba9a944065ee0c94130ff780114317307797a1f323e.json b/.sqlx/query-ea25cd3247cbe31212015ba9a944065ee0c94130ff780114317307797a1f323e.json new file mode 100644 index 00000000..46fac462 --- /dev/null +++ b/.sqlx/query-ea25cd3247cbe31212015ba9a944065ee0c94130ff780114317307797a1f323e.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO notifications (name, content)\n VALUES (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "ea25cd3247cbe31212015ba9a944065ee0c94130ff780114317307797a1f323e" +} diff --git a/.sqlx/query-eee86bceb10fb4edbb87e7e6df2f2d21e6b20c3830a8bcc9829febd3401a6c98.json b/.sqlx/query-eee86bceb10fb4edbb87e7e6df2f2d21e6b20c3830a8bcc9829febd3401a6c98.json new file mode 100644 index 00000000..fb769601 --- /dev/null +++ b/.sqlx/query-eee86bceb10fb4edbb87e7e6df2f2d21e6b20c3830a8bcc9829febd3401a6c98.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO posts\n (title, slug, author, content)\n VALUES (?, ?, ?, ?)\n RETURNING id, updated_at", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "updated_at", + "ordinal": 1, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 4 + }, + "nullable": [ + false, + false + ] + }, + "hash": "eee86bceb10fb4edbb87e7e6df2f2d21e6b20c3830a8bcc9829febd3401a6c98" +} diff --git a/.sqlx/query-ef4e33d3ac04980a5e3df62c1d8f1d8fcfe766edf9dcad5cda2a50bf28c83016.json b/.sqlx/query-ef4e33d3ac04980a5e3df62c1d8f1d8fcfe766edf9dcad5cda2a50bf28c83016.json new file mode 100644 index 00000000..a1b95dd3 --- /dev/null +++ b/.sqlx/query-ef4e33d3ac04980a5e3df62c1d8f1d8fcfe766edf9dcad5cda2a50bf28c83016.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "SELECT\n r.session_id,\n sp.name AS spot_name,\n r.contribution,\n u.email\n FROM rsvps r\n JOIN spots sp ON sp.id = r.spot_id\n LEFT JOIN users u ON u.id = r.user_id", + "describe": { + "columns": [ + { + "name": "session_id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "spot_name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "contribution", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "email", + "ordinal": 3, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + true + ] + }, + "hash": "ef4e33d3ac04980a5e3df62c1d8f1d8fcfe766edf9dcad5cda2a50bf28c83016" +} diff --git a/.sqlx/query-f00506273c315a0b8c34a224acc5e4b4921dcc6517f0900ebd246a055558f552.json b/.sqlx/query-f00506273c315a0b8c34a224acc5e4b4921dcc6517f0900ebd246a055558f552.json new file mode 100644 index 00000000..75990924 --- /dev/null +++ b/.sqlx/query-f00506273c315a0b8c34a224acc5e4b4921dcc6517f0900ebd246a055558f552.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "SELECT r.id as rsvp_id, r.spot_id, r.contribution\n FROM rsvps r\n JOIN rsvp_sessions rs ON rs.id = r.session_id\n WHERE rs.id = ?\n ", + "describe": { + "columns": [ + { + "name": "rsvp_id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "spot_id", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "contribution", + "ordinal": 2, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "f00506273c315a0b8c34a224acc5e4b4921dcc6517f0900ebd246a055558f552" +} diff --git a/.sqlx/query-f09aada9feb1c202c6134eb56fcb04d8c47af79fddeeceaea2da46ae28ad3f34.json b/.sqlx/query-f09aada9feb1c202c6134eb56fcb04d8c47af79fddeeceaea2da46ae28ad3f34.json new file mode 100644 index 00000000..561c2188 --- /dev/null +++ b/.sqlx/query-f09aada9feb1c202c6134eb56fcb04d8c47af79fddeeceaea2da46ae28ad3f34.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE rsvp_sessions\n SET stripe_payment_intent_id = ?,\n updated_at = CURRENT_TIMESTAMP\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "f09aada9feb1c202c6134eb56fcb04d8c47af79fddeeceaea2da46ae28ad3f34" +} diff --git a/.sqlx/query-f1a344a7296ccb6649b2ffc1c1ac0ea5730062c7b151e1cf9875c7222e24a110.json b/.sqlx/query-f1a344a7296ccb6649b2ffc1c1ac0ea5730062c7b151e1cf9875c7222e24a110.json new file mode 100644 index 00000000..e3fe889d --- /dev/null +++ b/.sqlx/query-f1a344a7296ccb6649b2ffc1c1ac0ea5730062c7b151e1cf9875c7222e24a110.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM list_members\n WHERE list_id = ? AND user_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "f1a344a7296ccb6649b2ffc1c1ac0ea5730062c7b151e1cf9875c7222e24a110" +} diff --git a/.sqlx/query-f2f80feffd0fdc343d5cce798dee6d228482d107e1e4962c057d3ed7e61a39ca.json b/.sqlx/query-f2f80feffd0fdc343d5cce798dee6d228482d107e1e4962c057d3ed7e61a39ca.json new file mode 100644 index 00000000..8a723079 --- /dev/null +++ b/.sqlx/query-f2f80feffd0fdc343d5cce798dee6d228482d107e1e4962c057d3ed7e61a39ca.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "SELECT r.id as rsvp_id, r.spot_id, r.contribution\n FROM rsvps r\n JOIN rsvp_sessions rs ON rs.id = r.session_id\n WHERE rs.event_id = ?\n AND rs.id != ?\n AND rs.status IN (?, ?, ?)", + "describe": { + "columns": [ + { + "name": "rsvp_id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "spot_id", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "contribution", + "ordinal": 2, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 5 + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "f2f80feffd0fdc343d5cce798dee6d228482d107e1e4962c057d3ed7e61a39ca" +} diff --git a/.sqlx/query-f3c177ab5c4606040719d144b87fb73c1bd55829925ece1e15cbe8b1bbb437f2.json b/.sqlx/query-f3c177ab5c4606040719d144b87fb73c1bd55829925ece1e15cbe8b1bbb437f2.json new file mode 100644 index 00000000..7a87da25 --- /dev/null +++ b/.sqlx/query-f3c177ab5c4606040719d144b87fb73c1bd55829925ece1e15cbe8b1bbb437f2.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM event_flyers WHERE event_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "f3c177ab5c4606040719d144b87fb73c1bd55829925ece1e15cbe8b1bbb437f2" +} diff --git a/.sqlx/query-fb9d4adb6c2f81ceb869c34fd26d188e9e2b3dbc7b951ff9c00db06be355c7c9.json b/.sqlx/query-fb9d4adb6c2f81ceb869c34fd26d188e9e2b3dbc7b951ff9c00db06be355c7c9.json new file mode 100644 index 00000000..b008b10c --- /dev/null +++ b/.sqlx/query-fb9d4adb6c2f81ceb869c34fd26d188e9e2b3dbc7b951ff9c00db06be355c7c9.json @@ -0,0 +1,164 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM events\n WHERE start <= DATETIME(CURRENT_TIMESTAMP, '-24 hours')\n AND unlisted = FALSE\n ORDER BY start DESC", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "title", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "slug", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "start", + "ordinal": 3, + "type_info": "Datetime" + }, + { + "name": "end", + "ordinal": 4, + "type_info": "Datetime" + }, + { + "name": "capacity", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "unlisted", + "ordinal": 6, + "type_info": "Bool" + }, + { + "name": "closed", + "ordinal": 7, + "type_info": "Bool" + }, + { + "name": "guest_list_id", + "ordinal": 8, + "type_info": "Integer" + }, + { + "name": "spots_per_person", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "description_html", + "ordinal": 10, + "type_info": "Text" + }, + { + "name": "description_updated_at", + "ordinal": 11, + "type_info": "Datetime" + }, + { + "name": "invite_subject", + "ordinal": 12, + "type_info": "Text" + }, + { + "name": "invite_html", + "ordinal": 13, + "type_info": "Text" + }, + { + "name": "invite_updated_at", + "ordinal": 14, + "type_info": "Datetime" + }, + { + "name": "invite_sent_at", + "ordinal": 15, + "type_info": "Datetime" + }, + { + "name": "confirmation_subject", + "ordinal": 16, + "type_info": "Text" + }, + { + "name": "confirmation_html", + "ordinal": 17, + "type_info": "Text" + }, + { + "name": "confirmation_updated_at", + "ordinal": 18, + "type_info": "Datetime" + }, + { + "name": "dayof_subject", + "ordinal": 19, + "type_info": "Text" + }, + { + "name": "dayof_html", + "ordinal": 20, + "type_info": "Text" + }, + { + "name": "dayof_updated_at", + "ordinal": 21, + "type_info": "Datetime" + }, + { + "name": "dayof_sent_at", + "ordinal": 22, + "type_info": "Datetime" + }, + { + "name": "created_at", + "ordinal": 23, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 24, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false + ] + }, + "hash": "fb9d4adb6c2f81ceb869c34fd26d188e9e2b3dbc7b951ff9c00db06be355c7c9" +} diff --git a/.sqlx/query-fce172ce7eb8b9bcde96636f3153c57ec953e6096172028a5adfcbbc138e60c9.json b/.sqlx/query-fce172ce7eb8b9bcde96636f3153c57ec953e6096172028a5adfcbbc138e60c9.json new file mode 100644 index 00000000..2d083c66 --- /dev/null +++ b/.sqlx/query-fce172ce7eb8b9bcde96636f3153c57ec953e6096172028a5adfcbbc138e60c9.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "UPDATE EVENTS\n SET dayof_subject = ?,\n dayof_html = ?,\n dayof_updated_at = CURRENT_TIMESTAMP,\n updated_at = CURRENT_TIMESTAMP\n WHERE id = ?\n RETURNING dayof_updated_at", + "describe": { + "columns": [ + { + "name": "dayof_updated_at", + "ordinal": 0, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + true + ] + }, + "hash": "fce172ce7eb8b9bcde96636f3153c57ec953e6096172028a5adfcbbc138e60c9" +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..b502e78a --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + "recommendations": [ + "bradlc.vscode-tailwindcss", + "rust-lang.rust-analyzer", + "esbenp.prettier-vscode", + "samuelcolvin.jinjahtml", + "dotenv.dotenv-vscode", + "tamasfe.even-better-toml" + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..b4dea7d5 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,17 @@ +{ + "[css]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[html]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[jinja-html]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "files.associations": { + "*.css": "tailwindcss", + "*.html": "jinja-html" + }, + "prettier.configPath": "frontend/prettier.config.cjs", + "prettier.requireConfig": true +} \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 2edaac27..5c988c1b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,29 +1,47 @@ [package] -name = "wlsd" +name = "lsd" version = "0.1.0" -edition = "2021" +edition = "2024" [dependencies] -axum = { version = "0.7", default-features = false, features = ["query", "form", "matched-path"] } +axum = { version = "0.8", default-features = false, features = ["http2", "query", "form", "json", "multipart", "tokio"] } axum-server = { version = "0.7", features = ["tls-rustls"] } -axum-extra = { version = "0.9", features = ["cookie"] } -tower-http = { version = "0.6", features = ["fs", "trace"] } -tera = "1" -sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio"] } +axum-extra = { version = "0.10", features = ["cookie"] } +askama = { version = "0.13", features = ["serde_json"] } +askama_web = { version = "0.13", features = ["axum-0.8"] } +cookie = "0.18" lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "pool", "smtp-transport", "tokio1", "tokio1-rustls-tls", "serde"] } -tokio = { version = "1", features = ["rt-multi-thread", "fs", "net", "sync", "macros"] } rustls = "0.23" rustls-acme = { version = "0.12", features = ["axum"] } +sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio", "chrono"] } +tokio = { version = "1", features = ["rt-multi-thread", "fs", "net", "sync", "macros"] } +tokio_schedule = "0.3" +tower = "0.5" +tower-http = { version = "0.6", features = ["fs", "set-header", "compression-br", "request-id", "trace", "util"] } +tower-serve-static = "0.1" +reqwest = { version = "0.12", default-features = false, features = ["http2", "charset", "json", "multipart", "rustls-tls"] } +sentry = { version = "0.46", default-features = false, features = ["tracing", "backtrace", "contexts", "debug-images", "panic", "release-health", "tower-axum-matched-path", "reqwest", "rustls"]} +backtrace = "0.3" -anyhow = "1" -tracing = "0.1" -tracing-subscriber = "0.3" +thiserror = "2" +chrono-tz = { version = "0.10", features = ["serde"] } +chrono = { version = "0.4", features = ["serde"] } futures = "0.3" +async-stream = "0.3" +rand = "0.8" serde = { version = "1", features = ["derive"] } +serde_json = "1" toml = "0.8" -rand = "0.8" - +tracing = "0.1" +tracing-subscriber = "0.3" +uuid = { version = "1", features = ["v7"] } +hmac = "0.12" +sha2 = "0.10" +hex = "0.4" +base64 = "0.22" +image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } +jpeg-encoder = "0.6" # Add a little optimization to debug builds [profile.dev] @@ -32,8 +50,6 @@ opt-level = 1 [profile.dev.package."*"] opt-level = 3 -# Production build with more intense optimization -[profile.prod] -inherits = "release" -lto = true -codegen-units = 1 +# Include line tables so Sentry stack traces show function names and line numbers +[profile.release] +debug = 1 diff --git a/README.md b/README.md index d863e93d..b9ca01fc 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ -# WLSD +# lightandsound.design Coming to you live. ## Setup Install a rust toolchain with [rustup.rs](https://rustup.rs): + ```sh curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh cargo --version @@ -12,18 +13,38 @@ rustc --version ``` Clone the repo: + +```sh +git clone https://github.com/foltik/lsd +cd lsd +``` + +Create a .env file with `DATABASE_URL=sqlite://db.sqlite` + +Initialize dev database: + +```sh +cargo install sqlx-cli --no-default-features --features sqlite +cargo sqlx database setup +``` + +To auto rebuild CSS and live-reload changes to your browser, use the `watch` npm script: + ```sh -git clone https://github.com/foltik/wlsd -cd wlsd +npm install +npm run watch ``` -To automatically recompile and rerun when you make changes, use `cargo-watch`: +To auto recompile and restart the backend when you make changes, use `cargo-watch`: + ```sh cargo install cargo-watch -cargo watch -x 'run config/dev.toml' +cargo watch -x 'run config/dev.toml' -w src -w frontend/templates ``` + Use [mailtutan](https://github.com/mailtutan/mailtutan) for local testing of email functionality: + ```sh cargo install mailtutan mailtutan @@ -31,5 +52,12 @@ mailtutan ## Workflow -* Make commits in a separate branch, and open a PR against `main` -* When new commits land in `main`, a github action will automatically deploy the app to https://wlsd.foltz.io +- Make commits in a separate branch, and open a PR against `main` +- When new commits land in `main`, a github action will automatically deploy the app to https://beta.lightandsound.design + + +# Special thanks + +This project was made possible by open source software. + +* Our rich text editor was built with inspiration from [https://github.com/jaredreich/pell](pell.js). diff --git a/askama.toml b/askama.toml new file mode 100644 index 00000000..8275a9a6 --- /dev/null +++ b/askama.toml @@ -0,0 +1,2 @@ +[general] +dirs = ["frontend/templates"] diff --git a/config/dev.toml b/config/dev.toml index abea9524..aac2f79a 100644 --- a/config/dev.toml +++ b/config/dev.toml @@ -1,11 +1,27 @@ [app] +domain = "localhost" url = "https://localhost:4433" -db = "db.sqlite" +tz = "America/New_York" +session_expiry_days = 365 + +[db] +file = "db.sqlite" +seed_data = "config/seed_data.sql" [net] http_addr = "[::]:8080" https_addr = "[::]:4433" [email] -addr = "smtp://localhost:1025" -from = "WLSD " +smtp_addr = "smtp://localhost:1025" +from = "Light and Sound Design " + +[stripe] +publishable_key = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" +secret_key = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" +webhook_key = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + +[cloudflare] +# Test keys (see https://developers.cloudflare.com/turnstile/troubleshooting/testing) +turnstile_site_key = "1x00000000000000000000BB" +turnstile_secret_key = "1x0000000000000000000000000000000AA" diff --git a/config/prod.toml b/config/prod.toml index 2e966456..b400d982 100644 --- a/config/prod.toml +++ b/config/prod.toml @@ -1,17 +1,39 @@ [app] -url = "https://wlsd.lightandsound.design" -db = "db.sqlite" +domain = "beta.lightandsound.design" +url = "https://beta.lightandsound.design" +tz = "America/New_York" +session_expiry_days = 30 + +[db] +file = "db.sqlite" [net] http_addr = "[::]:80" https_addr = "[::]:443" [acme] -domain = "wlsd.lightandsound.design" -email = "studio249@foltz.io" +domain = "beta.lightandsound.design" +email = "$EMAIL_FROM" dir = "acme" prod = true [email] -addr = "smtp://localhost:1080" -from = "WLSD " +smtp_addr = "smtp://email-smtp.us-east-1.amazonaws.com?tls=required" +smtp_username = "$SMTP_USERNAME" +smtp_password = "$SMTP_PASSWORD" +ratelimit = 10 +from = "Light and Sound Design <$EMAIL_FROM>" +newsletter_reply_to = "$EMAIL_NEWSLETTER_REPLY_TO" +contact_to = "$EMAIL_CONTACT_TO" + +[stripe] +publishable_key = "$STRIPE_PUBLISHABLE_KEY" +secret_key = "$STRIPE_SECRET_KEY" +webhook_key = "$STRIPE_WEBHOOK_KEY" + +[cloudflare] +turnstile_site_key = "$CF_TURNSTILE_SITE_KEY" +turnstile_secret_key = "$CF_TURNSTILE_SECRET_KEY" + +[sentry] +dsn = "$SENTRY_DSN" diff --git a/config/seed_data.sql b/config/seed_data.sql new file mode 100644 index 00000000..6ab2ad6a --- /dev/null +++ b/config/seed_data.sql @@ -0,0 +1,62 @@ +INSERT OR IGNORE INTO users (id, email, first_name, last_name) VALUES + (1, 'admin@beta.lightandsound.design', 'Add', 'Min'), + (2, 'writer@beta.lightandsound.design', 'Wri', 'Ter'), + (3, 'user1@beta.lightandsound.design', 'User1', 'One'), + (4, 'user2@beta.lightandsound.design', 'User2', 'Two'), + (5, 'user3@beta.lightandsound.design', 'User3', 'Three'); + +INSERT OR IGNORE INTO user_history (user_id, version, email, first_name, last_name, phone) + SELECT id AS user_id, 0 AS version, email, first_name, last_name, phone + FROM users; + +INSERT OR IGNORE INTO user_roles (user_id, role) VALUES + (1, 'admin'), + (1, 'writer'), + (2, 'writer'); + +INSERT OR IGNORE INTO session_tokens (user_id, token) VALUES + (1, '91acde7529be7cf7'), + (2, '2e2a134702ce9c1c'), + (3, '7bb038c99877731d'), + (4, 'aa765c35ba99434b'), + (5, 'd82952210bb78d53'); + +INSERT OR IGNORE INTO lists (id, name, description) VALUES + (1, 'Newsletter', 'the Studio newsletter!'), + (2, 'Test Group 1', 'the Studio test group 1!'), + (3, 'Test Group 2', 'the Studio test group 2!'); +INSERT OR IGNORE INTO list_members (list_id, user_id) VALUES + (1, 1), + (1, 2), + (1, 3), + (1, 4), + (1, 5), + (2, 3), + (2, 4), + (2, 5), + (3, 1); + +INSERT OR IGNORE INTO events (id, title, slug, description_html, description_updated_at, start, end, capacity, unlisted) VALUES + (1, 'An upcoming person will Present Sounds', 'upcoming-present-sounds', 'An upcoming person will present sounds.', '2024-01-01 00:00:00', '2026-07-31 23:00:00', '2026-08-01 03:00:00', 2, 0), + (4, 'Another upcoming person will Present Sounds', 'upcoming-present-sounds-2', 'An upcoming person will present sounds.', '2024-01-01 00:00:00', '2026-08-31 23:00:00', '2026-09-01 03:00:00', 2, 0), + (3, 'A past person will Present Sounds', 'past-present-sounds', 'A past person will present sounds.', '2024-01-01 00:00:00', '2024-08-14 23:00:00', '2024-08-15 03:00:00', 2, 0), + (2, 'Another past person will Present Sounds', 'past-present-sounds-2', 'A past person will present sounds.', '2024-01-01 00:00:00', '2024-07-14 23:00:00', '2024-07-15 03:00:00', 2, 0); +INSERT OR IGNORE INTO spots (id, name, description, qty_total, qty_per_person, kind, sort, required_contribution, min_contribution, max_contribution, suggested_contribution, required_notice_hours) VALUES + (1, 'Free!', 'Brand new cherry red ferrarri!', 1, 1, 'free', 0, NULL, NULL, NULL, NULL, NULL), + (2, 'Accessibility Contribution', 'When I pay less, I know I am letting my community hold me and support me.', 2, 1, 'fixed', 1, 20, NULL, NULL, NULL, NULL), + (3, 'Standard Contribution', 'When I pay in the suggested amount, I know I am helping the organizers cover costs.', 10, 4, 'fixed', 2, 25, NULL, NULL, NULL, NULL), + (4, 'Sustainability Contribution', 'When I pay more, I know that I am helping others to access the event and doing my part to make sure that the studio can continue it''s accessibility model.', 10, 4, 'fixed', 3, 30, NULL, NULL, NULL, NULL), + (5, 'Work Trade', 'When I volunteer my time, I know that I am contributing a valuable resource to my community.', 2, 1, 'work', 4, NULL, NULL, NULL, NULL, 4), + (6, 'Standard Contribution', 'When I pay in the suggested amount, I know I am helping the organizers cover costs.', 10, 4, 'fixed', 2, 25, NULL, NULL, NULL, NULL); +INSERT OR IGNORE INTO event_spots (event_id, spot_id) VALUES + (1, 1), + (1, 2), + (1, 3), + (1, 4), + (1, 5), + (2, 6); + +INSERT OR IGNORE INTO posts (id, title, slug, author, content) VALUES + (1, '[4.16-4.30]', '41643025', 'LSD', '

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent eu ultricies dui. Morbi sit amet vestibulum urna, eget elementum justo. In tincidunt mattis consequat. Etiam dapibus blandit ipsum, vel vehicula nibh pellentesque sed. Vivamus et luctus est. Ut at vulputate massa, a tincidunt odio. Nunc mattis aliquam felis, non gravida eros tristique eget.


Nunc odio lorem, vestibulum vel purus a, egestas auctor est. Fusce id ligula ac turpis mattis gravida. Fusce orci leo, pulvinar at iaculis et, efficitur ac dui. Nulla facilisi. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Aliquam rhoncus dignissim purus et varius. Donec facilisis ipsum a sapien accumsan porta. Suspendisse ultrices ac ipsum sit amet dictum. Morbi quis purus fringilla orci congue venenatis. Curabitur ex mauris, semper auctor enim ut, hendrerit vulputate erat. Pellentesque imperdiet, tortor id pharetra tempus, ante ipsum semper ligula, ac tincidunt lacus risus nec est. Proin vel arcu interdum, aliquam augue nec, feugiat velit. Aenean gravida vestibulum nulla ac rutrum. Vivamus nunc neque, egestas vitae nulla malesuada, fermentum egestas mauris. Mauris sollicitudin vehicula urna, id convallis risus vestibulum a. Pellentesque consequat interdum commodo.


4.17 | fieldtalk will Present Sounds



In auctor erat ut fermentum lacinia. Curabitur pulvinar dui magna, a euismod mi aliquet at. Duis non lectus eget est tincidunt vulputate. Nunc sit amet ante elit. Proin maximus odio nec hendrerit malesuada. Curabitur finibus ornare elit ac tempor. Curabitur scelerisque malesuada purus, tristique suscipit justo molestie at. In hac habitasse platea dictumst.


4.18 | Zen / Echo 



Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Nullam tincidunt est est, ac feugiat mi maximus sit amet. Sed nec convallis nunc. Duis hendrerit arcu at nisl bibendum tempor. Sed tincidunt venenatis odio, at lobortis dolor sagittis a. Integer in ultrices ipsum. In nec dui a dui gravida scelerisque malesuada vel nunc. Mauris eu tellus et arcu blandit ultrices.


4.19 | A Cello''s Banquet



Maecenas non elementum erat. Vivamus nibh arcu, auctor sit amet turpis sed, volutpat blandit lorem. Praesent eros nisl, varius et ligula sit amet, molestie rutrum ipsum. Integer tempus erat sit amet odio vehicula suscipit. Donec id purus ornare, interdum leo ac, sollicitudin quam. In hac habitasse platea dictumst. Duis nulla magna, dapibus id tellus vel, lobortis dignissim dui. Aenean vehicula, turpis ac scelerisque scelerisque, velit ex scelerisque ex, non gravida mauris mauris non magna. Etiam a facilisis purus, quis tincidunt lacus. Maecenas erat dui, volutpat non tempus non, gravida elementum libero. Nullam quis dapibus lacus, non faucibus odio. Aliquam in lorem metus.

'), + (2, '[4.30-5.13]', '43051325', 'LSD', '

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas pharetra placerat velit ut pulvinar. Aenean imperdiet neque a neque vehicula consequat. Maecenas vel libero id nisl tincidunt dignissim ac ac ex. Phasellus congue felis ut neque interdum, at aliquet nunc consequat. Nullam posuere enim nec erat varius, nec semper sapien elementum. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Cras semper turpis vitae malesuada aliquet.


Aliquam orci purus, fringilla sit amet aliquam vel, porttitor quis nisl. Phasellus eget eros volutpat lorem luctus luctus in in augue. Ut convallis nibh ut suscipit placerat. Donec at diam eget diam convallis porttitor. Donec augue urna, rutrum at velit a, venenatis ornare massa. Ut elementum dapibus ultricies. Vivamus bibendum turpis quis rhoncus volutpat. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Aliquam nec lacus in quam luctus convallis sit amet vitae justo. Etiam sed blandit purus, tincidunt ultrices lacus. Cras eget elit malesuada, luctus magna ac, lobortis elit. Sed sit amet risus ut justo tempus lobortis nec non libero. Praesent hendrerit risus ac tempus suscipit. In vulputate, nunc ut iaculis varius, nibh massa dapibus velit, nec ultrices dui elit et sapien.


Cras ac turpis at eros euismod viverra. Nulla id metus a velit consectetur rhoncus. Nulla sagittis neque elit, at pretium dolor laoreet at. Nam feugiat leo non eleifend porta. Maecenas eget felis ullamcorper, venenatis magna sed, facilisis nibh. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Aenean vehicula rutrum finibus. Sed lacinia neque sed mauris pellentesque, ut mattis quam tempor. Ut lacinia lacus dui, ac rhoncus odio vulputate sagittis. Suspendisse nec ultrices ligula, ac sagittis lorem. Mauris porttitor, urna rhoncus efficitur eleifend, leo erat rhoncus justo, ut sagittis eros lectus non magna. Nulla nec sapien a nulla scelerisque fringilla eu gravida velit. Vestibulum vehicula dictum lectus.


5.30 | A.B.E.L.A.



Integer eget lacus sed sem vehicula malesuada. Pellentesque quis maximus justo. Phasellus viverra sodales elit ac egestas. Proin dapibus et nisl a consequat. Nunc id sem vitae mauris mattis consectetur. Donec at semper ipsum, ut faucibus elit. Nunc et condimentum lorem. Sed vulputate sollicitudin bibendum. Maecenas hendrerit urna vel cursus dictum. Suspendisse eu nibh quis augue ornare dignissim. Aenean ante justo, sagittis ac dolor at, feugiat interdum odio. Mauris nec dignissim nisi, at luctus nisl. Etiam ultricies vulputate volutpat.


5.1 | David First will Present Sounds



In blandit tincidunt lorem vel euismod. Nulla blandit, arcu eu ullamcorper iaculis, est augue volutpat nulla, eget tincidunt dolor mi ac sapien. Morbi porta aliquam mollis. Phasellus efficitur turpis a justo luctus, sit amet fermentum quam tincidunt. Cras tortor risus, rutrum eget sem ut, cursus rhoncus justo. Maecenas sollicitudin sodales maximus. Aenean in tortor viverra, imperdiet tortor et, finibus mauris. Sed euismod turpis sed enim lobortis accumsan. Donec vehicula pharetra est nec vulputate. Cras sit amet massa in enim fringilla ornare nec sed ex. Etiam vel sapien metus. Quisque vehicula cursus ipsum et volutpat. Nunc tincidunt venenatis ante, non cursus arcu congue euismod. Curabitur a lectus eleifend, convallis nunc quis, lacinia metus. Pellentesque vel erat in dolor porttitor rhoncus.


5.2 | Xhibit: A Community Arts Salon



Phasellus fermentum leo dui, quis ultricies est sodales et. Donec sit amet arcu quis quam vehicula varius. Mauris sagittis interdum arcu et tempus. Integer eu enim nec dui feugiat consequat et ut lacus. Suspendisse sit amet ullamcorper mauris, rhoncus pulvinar odio. Fusce at metus mattis, sollicitudin sapien in, tempus ante. Nulla placerat nibh eu est eleifend maximus. Etiam quis libero sit amet risus pharetra aliquam. In felis metus, commodo in mattis in, mollis sed nisl. Nulla pharetra turpis at eleifend lobortis. Aenean finibus, massa vel tristique euismod, magna est mattis nibh, lobortis ultrices neque arcu aliquam sem. Phasellus quam nisl, varius et tristique vitae, aliquam id elit. Fusce dapibus libero et tortor luctus tristique. Suspendisse quis ligula purus. Nullam vitae augue quam.

'), + (3, '[5.14-5.20]', '51452025', 'LSD', '

Nulla at sapien at tortor feugiat aliquam id quis nisl. Donec posuere nec justo sed molestie. Fusce ac ante justo. Morbi vitae magna eget velit vestibulum varius sed sit amet eros. Proin vestibulum sed erat eget volutpat. Vivamus non tincidunt diam, sed consectetur leo. Nunc felis ante, hendrerit et arcu rhoncus, sagittis vestibulum nisl.


Donec accumsan sapien tincidunt, suscipit tellus ut, ultricies tellus. Proin eu ex bibendum lacus mollis malesuada. Morbi dictum enim nec purus volutpat euismod. Ut interdum lacinia est et interdum. Fusce erat est, tincidunt non ipsum vitae, ullamcorper blandit velit. Phasellus pretium quam sed libero ullamcorper, at faucibus ante vulputate. Fusce egestas consequat massa at bibendum. Etiam euismod augue neque, id dictum ex facilisis vel.


5.7 | Jason Lindner x Currency Audio, Ben Shirken, Yaz Lancaster, Amelia Holt 



Integer massa erat, eleifend molestie condimentum in, aliquam quis tellus. Aliquam sodales id ante id vulputate. Sed et purus et nisl blandit ullamcorper. Donec quis posuere risus. Nam pellentesque mollis lacus, ut suscipit quam iaculis id. Quisque massa lacus, blandit non venenatis nec, eleifend sed eros. Proin mollis ultrices ultrices. Suspendisse nec viverra dolor. Proin hendrerit tortor quam, non tempus mauris aliquet vel. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.


5.14 | Writing on Raving Book Launch



Mauris orci leo, commodo vel erat in, fermentum consectetur magna. Duis dignissim dapibus sapien. Curabitur non sem a augue ultrices rutrum mattis vitae lorem. Quisque luctus, mauris non dapibus vulputate, mauris lacus tincidunt erat, nec efficitur metus erat ac eros. Integer maximus dui sed iaculis porttitor. Sed ipsum libero, mattis eget dui id, rhoncus tempus mi. Quisque pulvinar purus ac ante posuere semper.


Cras enim neque, luctus in mi eget, pulvinar semper neque. Mauris erat nibh, facilisis quis maximus ac, malesuada sed dui. Ut vitae consectetur nisi, eu commodo dolor. Vestibulum at tempor massa, et aliquam metus. Praesent et nunc a turpis aliquet euismod eu et ex. Aenean a gravida odio. Nullam at urna lobortis, vulputate nunc at, congue enim. Vivamus id enim et libero convallis accumsan non at dolor. Suspendisse tincidunt ligula aliquam, iaculis arcu vel, lobortis nisi. Morbi euismod, lectus sed suscipit imperdiet, nunc quam faucibus neque, vel congue libero metus volutpat risus. Nam semper a nulla vitae cursus. Curabitur justo tortor, lacinia id consectetur sed, maximus nec mi. Maecenas pretium neque ac erat dignissim, sit amet interdum justo aliquam. Proin commodo diam sed molestie porttitor.


5.15 | Akanbi will Present Sounds



Vestibulum pretium facilisis enim, vel iaculis justo faucibus nec. Pellentesque gravida dolor sit amet sapien egestas, non mattis elit varius. Morbi sollicitudin magna nec pretium suscipit. Aenean cursus ultricies nunc, eget fermentum orci placerat molestie. Proin volutpat convallis lorem, non ultricies quam fringilla in. Curabitur quis odio non nibh facilisis convallis. Maecenas a sagittis urna. Nunc at tempor erat, vitae gravida lorem. Nunc quis mi turpis. Aliquam mi ex, interdum pretium consequat non, dictum ut libero. Donec mattis condimentum auctor. Vivamus consectetur scelerisque metus, non pellentesque odio feugiat a. Sed suscipit feugiat libero nec dictum. Nullam porta tincidunt molestie. Curabitur placerat maximus dolor et hendrerit.


5.16 | Good Grief: Handle With Care



Vestibulum feugiat, tellus dapibus maximus pellentesque, felis eros scelerisque purus, sit amet pretium turpis enim ut orci. Maecenas in facilisis leo, id interdum dui. Curabitur id mauris eu urna iaculis scelerisque non eget diam. Sed condimentum libero sed nulla efficitur, in commodo turpis rhoncus. Nam non elit imperdiet, convallis dolor sed, luctus sem. Nullam tempor nisl id odio pellentesque placerat eu et turpis. Donec dapibus non erat et ornare. Proin id porttitor urna. Nulla accumsan condimentum diam ac bibendum. Maecenas ante enim, bibendum non justo a, finibus consectetur dolor. Nunc condimentum et dolor at maximus. Curabitur eget ex justo. In vitae odio ac libero pulvinar semper sed non libero. Suspendisse risus orci, ultrices id commodo in, feugiat quis sem. Suspendisse in sem vitae purus vestibulum facilisis id in turpis. Ut facilisis nunc ut nunc condimentum volutpat.

'); diff --git a/frontend/prettier.config.cjs b/frontend/prettier.config.cjs new file mode 100644 index 00000000..63f9b4a8 --- /dev/null +++ b/frontend/prettier.config.cjs @@ -0,0 +1,16 @@ +const config = { + plugins: [ + require.resolve("prettier-plugin-jinja-template"), + require.resolve("prettier-plugin-tailwindcss"), + ], + overrides: [ + { + files: ["*.html"], + options: { + parser: "jinja-template", + }, + }, + ], +}; + +module.exports = config; diff --git a/frontend/static/bulletin/bulletin.js b/frontend/static/bulletin/bulletin.js new file mode 100644 index 00000000..d6982479 --- /dev/null +++ b/frontend/static/bulletin/bulletin.js @@ -0,0 +1,540 @@ +let clickedElement = null; +let isDraggingFlyer = false; + +// Convenience singleton for accessing some static page elements +const App = { + board: document.getElementById("board"), + addPosterButton: document.getElementById("add-poster-button"), + editForm: document.getElementById("edit-flyer-form"), + editFlyer: document.getElementById("edit-flyer"), +}; + +// Some state that is global to the bulletin board app +const AppState = { + scale: 1.0, + centerX: 0, + centerY: 0, + + isInLoadingAnimation: false, +}; + +// Read #x=N&y=N from the URL hash and apply it as the board center. +// If missing or invalid, pick a random nearby starting position and write it to the hash. +function updateCoordinatesFromHash() { + const params = new URLSearchParams(globalThis.location.hash.slice(1)); + let x = parseInt(params.get("x") || "NaN"); + let y = parseInt(params.get("y") || "NaN"); + + if (isNaN(x) || isNaN(y)) { + const range = 2000; + x = Math.round((Math.random() * 2 - 1) * range); + y = Math.round((Math.random() * 2 - 1) * range); + history.replaceState(null, "", `#x=${x}&y=${y}`); + } + + AppState.centerX = x; + AppState.centerY = y; + App.board.style.setProperty("--center-x", `${x}px`); + App.board.style.setProperty("--center-y", `${y}px`); +} + +// Get the x, y coordinates on the bulletin board of the mouse pointer +function getWorldMousePosition(event) { + const x = + AppState.centerX + + (event.clientX - globalThis.innerWidth / 2) / AppState.scale; + const y = + -AppState.centerY + + (event.clientY - globalThis.innerHeight / 2) / AppState.scale; + return [x, y]; +} + +function showAddPosterButton(x, y) { + // button is only conditionally rendered based on user login/role + if (!App.addPosterButton) return; + + App.addPosterButton.style.setProperty("--x", `${x - 15}px`); + App.addPosterButton.style.setProperty("--y", `${-y + 15}px`); + App.addPosterButton.hidden = false; +} + +function hideAddPosterButton() { + if (!App.addPosterButton) return; + App.addPosterButton.hidden = true; +} + +// Unhide the edit UI for a given flyer +function showEditUI(element) { + clickedElement = element; + // TODO(sam) all in one div? + clickedElement.querySelector(".rotate-dot").hidden = false; + clickedElement.querySelector(".rotate-link").hidden = false; + clickedElement.querySelector(".edit-button").hidden = false; +} + +function hideEditUI() { + if (clickedElement) { + clickedElement.querySelector(".rotate-dot").hidden = true; + clickedElement.querySelector(".rotate-link").hidden = true; + clickedElement.querySelector(".edit-button").hidden = true; + + clickedElement = null; + } +} + +// Get the angle between an element and the mouse position for rotation +function getAngle(element, clientX, clientY) { + const rect = element.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + + // Calculate angle in radians, then convert to degrees + return Math.atan2(clientY - centerY, clientX - centerX) * (180 / Math.PI); +} + +// Setup the event listeners to add interactivity to each individual flyer element +function setupEventListeners(element) { + // When dragging the flyer, the starting coordinates of the movement in screen space + let startX = 0; + let startY = 0; + + // Starting coordinates of the flyer in world space + let originalX = 0; + let originalY = 0; + + let originalZIndex = 0; + + // Updated x and y coordinates of the flyer in world space + let newX = 0; + let newY = 0; + + // State flags + let isDragging = false; + let hasChanged = false; + + // Rotation state + let rotating = false; + let initialRotation = 0; + let initialAngle = 0; + + element.addEventListener( + "click", + async (e) => { + if (e.target.closest(".edit-button")) { + // Show the edit form and populate it with existing data for the flyer + const id = parseInt(element.id); + const flyerDetails = await ( + await fetch(`/bulletin/flyer/${id}`) + ).json(); + + App.editForm.querySelector('input[name="link_url"]').value = + flyerDetails.link_url ?? ""; + App.editForm.querySelector('input[name="flyer_name"]').value = + flyerDetails.flyer_name; + + App.editForm.action = `/bulletin/flyer/${id}/edit`; + + App.editFlyer.showPopover(); + } + }, + { passive: true }, + ); + + element.addEventListener( + "pointerdown", + (e) => { + // Prevent right clicks + if (e.button !== 0) return; + + hideAddPosterButton(); + + // Capture pointer events for dragging, but exclude edit-button target so that it stays clickable + if (!e.target.closest(".edit-button")) { + element.setPointerCapture(e.pointerId); + } + + if (e.target.classList.contains("rotate-dot")) { + rotating = true; + initialRotation = + parseInt(element.style.getPropertyValue("--rotation")) || 0; + initialAngle = getAngle(element, e.clientX, e.clientY); + } else { + // isDraggingFlyer is the global state and used to prevent the background from moving while the flyer is being dragged + isDragging = true; + isDraggingFlyer = true; + hasChanged = false; + + // Bring element to top temporarily for moving + originalZIndex = element.style.zIndex; + element.style.zIndex = 2147483647; + + originalX = parseInt(element.style.getPropertyValue("--x")); + originalY = parseInt(element.style.getPropertyValue("--y")); + + startX = e.clientX / AppState.scale - originalX; + startY = -e.clientY / AppState.scale - originalY; + + console.log({ startX, startY, originalX, originalY }); + } + }, + { passive: true }, + ); + + element.addEventListener( + "pointermove", + (e) => { + if (isDragging) { + hasChanged = true; + + newX = e.clientX / AppState.scale - startX; + newY = -e.clientY / AppState.scale - startY; + + newX = Math.max(-10000, Math.min(6000, newX)); + newY = Math.max(-10000, Math.min(6000, newY)); + + requestAnimationFrame(() => { + element.style.setProperty("--x", `${Math.round(newX)}px`); + element.style.setProperty("--y", `${Math.round(newY)}px`); + }); + } else if (rotating) { + const currentAngle = getAngle(element, e.clientX, e.clientY); + const angleDiff = currentAngle - initialAngle; + const newRotation = (initialRotation + angleDiff) % 360; + + hasChanged = true; + + requestAnimationFrame(() => { + element.style.setProperty( + "--rotation", + `${Math.round(newRotation)}deg`, + ); + }); + } + }, + { passive: true }, + ); + + element.addEventListener( + "pointerup", + async (e) => { + element.releasePointerCapture(e.pointerId); + + if (isDragging) { + isDragging = false; + isDraggingFlyer = false; + element.style.zIndex = originalZIndex; + + // I frankly don't understand why the hasChanged check is necessary + // but if it's not there the flyer jumps far away when it is clicked + if ( + !hasChanged || + (Math.abs(newX - originalX) < 0.5 && Math.abs(newY - originalY) < 0.5) + ) { + // Since we haven't moved the flyer, interpret this as a click event and toggle the edit UI for the flyer + if (!clickedElement) { + showEditUI(element); + } else { + hideEditUI(); + } + } else { + // The flyer has moved, send its new position to the server + const flyerUpdate = JSON.stringify({ + x: Math.round(newX), + y: Math.round(newY), + rotation: parseInt(element.style.getPropertyValue("--rotation")), + }); + + const id = parseInt(element.id); + await fetch(`/bulletin/flyer/${id}/move`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: flyerUpdate, + }); + } + } else if (rotating) { + rotating = false; + + const id = parseInt(element.id); + const flyerUpdate = JSON.stringify({ + x: parseInt(element.style.getPropertyValue("--x")), + y: parseInt(element.style.getPropertyValue("--y")), + rotation: parseInt(element.style.getPropertyValue("--rotation")), + }); + + await fetch(`/bulletin/flyer/${id}/move`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: flyerUpdate, + }); + } + }, + { passive: true }, + ); +} + +// Duration in milliseconds of initial zoom-in on page load +const START_ANIMATION_DURATION = 2000; + +function setup() { + setupDocumentEventListeners(); + + setupFlyerEventListeners(); + setupFlyerPins(); + + globalThis.addEventListener("hashchange", updateCoordinatesFromHash); + updateCoordinatesFromHash(); + + App.board.style.setProperty("--scale", "0.5"); + + // Begin the initial zoom animation + requestAnimationFrame(animateZoom); +} + +// Global event listeners for moving around the board +function setupDocumentEventListeners() { + const dragState = { + // Cache for storing pointer events (pinch to zoom) + evCache: [], + prevDiff: -1, + + isDraggingWindow: false, + + // starting x, y of cursor relative to world origin + startingX: 0, + startingY: 0, + + hasDragged: false, + + originalCenterX: 0, + originalCenterY: 0, + }; + + document.addEventListener( + "pointerdown", + (e) => { + // ignore right clicks + if (e.button !== 0) return; + + // store multiple finger presses for pinch/zoom + dragState.evCache.push(e); + if (dragState.evCache.length > 1) return; + + const target = e.target; + + // remove rotation dot if it's showing on any flyer + if (clickedElement && !clickedElement.contains(target)) { + hideEditUI(); + } + + if (e.target !== App.addPosterButton) { + hideAddPosterButton(); + } + + // Only handle events that are on the door element + if (e.target !== App.board || dragState.isDraggingWindow) { + return; + } + + App.board.setPointerCapture(e.pointerId); + dragState.isDraggingWindow = true; + + dragState.originalCenterX = AppState.centerX; + dragState.originalCenterY = AppState.centerY; + + // starting coordinates of mouse relative to world origin + [dragState.startingX, dragState.startingY] = getWorldMousePosition(e); + + dragState.hasDragged = false; + }, + { passive: true }, + ); + + document.addEventListener( + "pointermove", + (e) => { + // Don't move the bulletin board if a flyer is the thing being dragged + if (isDraggingFlyer) return; + + const index = dragState.evCache.findIndex( + (cachedEv) => cachedEv.pointerId == e.pointerId, + ); + dragState.evCache[index] = e; + + if (dragState.evCache.length === 2 && !AppState.isInLoadingAnimation) { + // Handle pinch to zoom events + // Calculate the distance between the two touch points + const xDiff = + dragState.evCache[0].clientX - dragState.evCache[1].clientX; + const yDiff = + dragState.evCache[0].clientY - dragState.evCache[1].clientY; + const curDiff = Math.sqrt(xDiff * xDiff + yDiff * yDiff); + + if (dragState.prevDiff > 0) { + AppState.scale += (curDiff - dragState.prevDiff) / 500; + // Set the scale between 0.5 and 1.5 relative to how much the distance between the touch points has changed since the last update + AppState.scale = Math.min(Math.max(0.5, AppState.scale), 1.5); + // Only update the scale on screen refresh + requestAnimationFrame(() => { + App.board.style.setProperty("--scale", `${AppState.scale}`); + }); + } + + dragState.prevDiff = curDiff; + } else if (dragState.evCache.length === 1 && dragState.isDraggingWindow) { + // Handle click and drag on the bulletin board + dragState.hasDragged = true; + AppState.centerX = Math.floor( + dragState.startingX - + (e.clientX - globalThis.innerWidth / 2) / AppState.scale, + ); + AppState.centerY = -Math.floor( + dragState.startingY - + (e.clientY - globalThis.innerHeight / 2) / AppState.scale, + ); + + requestAnimationFrame(() => { + App.board.style.setProperty("--center-x", `${AppState.centerX}px`); + App.board.style.setProperty("--center-y", `${AppState.centerY}px`); + }); + } + }, + { passive: true }, + ); + + document.addEventListener( + "pointerup", + (e) => { + // Cleanup from dragging + + const index = dragState.evCache.findIndex( + (cachedEv) => cachedEv.pointerId === e.pointerId, + ); + dragState.evCache.splice(index, 1); + + if (dragState.evCache.length < 2) { + dragState.prevDiff = -1; + } + + if (e.target === App.board && !dragState.hasDragged) { + // Interpret this as a click event and show the add poster button where the mouse was clicked + [clickX, clickY] = getWorldMousePosition(e); + showAddPosterButton(clickX, clickY); + } + + if (!dragState.isDraggingWindow) return; + App.board.releasePointerCapture(e.pointerId); + dragState.isDraggingWindow = false; + dragState.hasDragged = false; + + history.replaceState( + null, + "", + `#x=${AppState.centerX}&y=${AppState.centerY}`, + ); + }, + { passive: true }, + ); + + document.addEventListener( + "dblclick", + (e) => { + // Prevent double tap to zoom on touch screens + e.preventDefault(); + }, + { passive: false }, + ); + + document.addEventListener( + "wheel", + (e) => { + // Handle scroll wheel zoom + if (AppState.isInLoadingAnimation) return; + AppState.scale += e.deltaY * -0.001; + AppState.scale = Math.min(Math.max(0.5, AppState.scale), 1.5); + requestAnimationFrame(() => { + App.board.style.setProperty("--scale", `${AppState.scale}`); + }); + }, + { passive: true }, + ); + + App.addPosterButton?.addEventListener( + "click", + () => { + hideAddPosterButton(); + document.getElementById("create-flyer").showPopover(); + // Populate the create-flyer form with the x and y coordinates in world space of the add poster button + const x = parseInt(App.addPosterButton.style.getPropertyValue("--x")); + const y = parseInt(App.addPosterButton.style.getPropertyValue("--y")); + document.querySelector('input[name="x"]').value = x; + document.querySelector('input[name="y"]').value = y; + }, + { passive: true }, + ); +} + +// Don't allow flyer editing unless the flyer is marked as editable +function setupFlyerEventListeners() { + App.board.querySelectorAll(".flyer.editable").forEach((element) => { + setupEventListeners(element); + }); +} + +// Set --flyer-half-height on each flyer so the pin's transform-origin +// can be anchored to the flyer's center, keeping the pin at the visual top. +function setupFlyerPins() { + App.board.querySelectorAll(".flyer").forEach((element) => { + const img = element.querySelector("img"); + if (!img) return; + const update = () => + element.style.setProperty( + "--flyer-half-height", + `${element.offsetHeight / 2}px`, + ); + if (img.complete) { + update(); + } else { + img.addEventListener("load", update, { once: true }); + } + }); +} + +const zoomState = { + startTime: 0, +}; + +function easeOutCubic(t) { + const t1 = t - 1; + return t1 * t1 * t1 + 1; +} + +// zoom in animation for page load +function animateZoom(now) { + if (zoomState.startTime === 0) { + // prevent user interaction during animation (it breaks things) + AppState.isInLoadingAnimation = true; + zoomState.startTime = now; + } + + const percentDone = (now - zoomState.startTime) / START_ANIMATION_DURATION; + if (percentDone >= 1) { + App.board.style.setProperty("--scale", "1"); + AppState.isInLoadingAnimation = false; + } else { + App.board.style.setProperty( + "--scale", + `${0.5 + easeOutCubic(percentDone) * 0.5}`, + ); + requestAnimationFrame(animateZoom); + } +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", setup); +} else { + setup(); +} diff --git a/frontend/static/bulletin/corkboard.jpg b/frontend/static/bulletin/corkboard.jpg new file mode 100644 index 00000000..cfda7e3a Binary files /dev/null and b/frontend/static/bulletin/corkboard.jpg differ diff --git a/assets/favicon.ico b/frontend/static/favicon.ico similarity index 100% rename from assets/favicon.ico rename to frontend/static/favicon.ico diff --git a/frontend/static/sublet/1.jpg b/frontend/static/sublet/1.jpg new file mode 100644 index 00000000..48f5a9b1 Binary files /dev/null and b/frontend/static/sublet/1.jpg differ diff --git a/frontend/static/sublet/2.jpg b/frontend/static/sublet/2.jpg new file mode 100644 index 00000000..195191c8 Binary files /dev/null and b/frontend/static/sublet/2.jpg differ diff --git a/frontend/static/sublet/3.jpg b/frontend/static/sublet/3.jpg new file mode 100644 index 00000000..efc3ff63 Binary files /dev/null and b/frontend/static/sublet/3.jpg differ diff --git a/frontend/static/sublet/6.jpg b/frontend/static/sublet/6.jpg new file mode 100644 index 00000000..be0cb5b5 Binary files /dev/null and b/frontend/static/sublet/6.jpg differ diff --git a/frontend/static/sublet/7.jpg b/frontend/static/sublet/7.jpg new file mode 100644 index 00000000..766783e0 Binary files /dev/null and b/frontend/static/sublet/7.jpg differ diff --git a/frontend/static/sublet/8.jpg b/frontend/static/sublet/8.jpg new file mode 100644 index 00000000..7646ad97 Binary files /dev/null and b/frontend/static/sublet/8.jpg differ diff --git a/frontend/styles/bulletin/admin.css b/frontend/styles/bulletin/admin.css new file mode 100644 index 00000000..d3f83377 --- /dev/null +++ b/frontend/styles/bulletin/admin.css @@ -0,0 +1,76 @@ +.admin-flyers { + @apply w-full border-collapse; + table-layout: fixed; + + th, + td { + @apply overflow-hidden border-b border-b-[#333] px-[14px] py-[10px] text-left align-top text-ellipsis whitespace-nowrap; + } + + th { + @apply bg-[#1a1a1a] text-[0.8rem] font-semibold tracking-[0.05em] whitespace-nowrap text-[#aaa] uppercase; + } + + img { + @apply block h-[80px] w-auto rounded-sm; + } + + td.col-link { + @apply text-lsd-blue; + + a { + @apply text-inherit no-underline hover:underline; + } + } + + details summary { + @apply text-lsd-blue cursor-pointer; + } + + .edit-form { + @apply mt-[10px] flex flex-col gap-[6px]; + + input { + @apply box-border w-full rounded-[3px] border border-[#444] bg-[#222] px-[6px] py-[4px] text-[#eee]; + } + + button { + @apply mt-[4px] cursor-pointer rounded-[3px] border border-[#555] bg-[#333] px-[12px] py-[4px] text-[#eee]; + } + } + + .delete-btn { + @apply text-lsd-red mt-[6px] block cursor-pointer; + } +} + +.col-image { + width: 110px; +} +.col-name { + width: 140px; +} +.col-user { + width: 70px; +} +.col-link { + width: 180px; +} +.col-pos { + width: 130px; +} +.col-actions { + width: 160px; +} + +.bulletin-admin { + @apply p-[24px]; + + h1 { + @apply my-[16px]; + } + + .pagination { + @apply my-[16px] flex items-center gap-[12px]; + } +} diff --git a/frontend/styles/bulletin/bulletin.css b/frontend/styles/bulletin/bulletin.css new file mode 100644 index 00000000..d5c0e763 --- /dev/null +++ b/frontend/styles/bulletin/bulletin.css @@ -0,0 +1,212 @@ +/* Scoped to pages that have a bulletin board */ +body:has(#board) { + @apply overflow-hidden; + touch-action: none; +} + +#board { + --center-x: 0px; + --center-y: 0px; + --scale: 1; + @apply absolute z-0; + width: 200vw; + height: 200vh; + left: -50vw; + top: -50vh; + background-image: url("/static/bulletin/corkboard.jpg"); + background-position: calc(0px - var(--center-x)) calc(var(--center-y)); + background-size: 800px 400px; + background-repeat: repeat; + transform: scale(var(--scale)); + + &:active { + @apply cursor-move; + } +} + +.board-boundary { + @apply pointer-events-none absolute box-border; + left: 50%; + top: 50%; + transform: translate3d( + calc(-10000px - var(--center-x)), + calc(var(--center-y) - 6000px), + 0 + ); + width: 20000px; + height: 12000px; + border: 4px dashed rgba(0, 0, 0, 0.55); +} + +.flyer { + --x: 0px; + --y: 0px; + --rotation: 0deg; + @apply absolute p-[3px]; + border: 1px solid #1a1a1a; + overflow: visible; + left: 50%; + top: 50%; + background: var(--color-lsd-white); + font-family: Georgia, "Times New Roman", Times, serif; + transform: translate3d( + calc(var(--x) - var(--center-x)), + calc(var(--center-y) - var(--y)), + 0 + ) + rotate(var(--rotation)); + box-shadow: calc(3px * cos(45deg - var(--rotation))) + calc(3px * sin(45deg - var(--rotation))) 2px rgba(0, 0, 0, 0.6); + user-select: none; + -webkit-user-select: none; + + &::before { + content: ''; + position: absolute; + width: 16px; + height: 16px; + border-radius: 50%; + background: + radial-gradient(circle at 50% 50%, rgba(0, 0, 0, 0.18) 0%, transparent 22%), + radial-gradient(ellipse at 36% 30%, #ffffff 0%, #dde0e8 22%, transparent 58%), + radial-gradient(circle at 50% 50%, #c4c8d4 0%, #a0a4b0 55%, #686878 100%); + top: 6px; + left: calc(50% - 8px); + z-index: 2; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.45), 0 1px 2px rgba(0, 0, 0, 0.3); + /* Rotate around the flyer's center so the pin stays at the visual top */ + transform-origin: 50% calc(var(--flyer-half-height, 100px) - 6px); + transform: rotate(calc(-1 * var(--rotation))); + pointer-events: none; + } + + &.editable { + @apply cursor-grab; + + &:active { + @apply cursor-grabbing; + } + } + + a { + @apply no-underline; + color: inherit; + user-select: none; + -webkit-user-select: none; + -webkit-user-drag: none; + } + + img { + -webkit-user-drag: none; + @apply max-h-[200px] max-w-[200px] select-none; + -webkit-user-select: none; + width: auto; + height: auto; + } + + &:has(> img.qr) { + @apply p-0; + } +} + +.rotate-dot { + @apply border-lsd-white absolute rounded-full border select-none; + top: -25px; + left: calc(50% - 6.5px); + height: 12px; + width: 12px; + background-color: var(--color-lsd-green); + -webkit-user-select: none; +} + +.rotate-link { + @apply absolute; + width: 1px; + height: 11px; + background-color: var(--color-lsd-green); + top: -12px; + left: calc(50% - 1px); + border: 1px solid var(--color-lsd-white); + border-top: none; + border-bottom: none; +} + +.edit-button { + @apply bg-lsd-white absolute rounded-full border border-[#eee] p-[6px]; + top: -14px; + left: -14px; +} + +.add-button { + --x: 0px; + --y: 0px; + @apply absolute flex cursor-pointer items-center justify-center rounded-lg p-[5px] select-none; + -webkit-user-select: none; + width: 30px; + height: 30px; + background-color: var(--color-lsd-black); + font-weight: bold; + font-size: x-large; + left: 50%; + top: 50%; + transform: translate3d( + calc(var(--x) - var(--center-x)), + calc(var(--center-y) - var(--y)), + 0 + ); + box-shadow: calc(3px * cos(45deg - var(--rotation))) + calc(3px * sin(45deg - var(--rotation))) 2px rgba(0, 0, 0, 0.6); +} + +[popover] { + @apply border-none bg-transparent; +} + +.outer-popover:has(> :popover-open) { + @apply absolute inset-0 z-[999999998]; + backdrop-filter: blur(3px); +} + +.middle-popover { + @apply select-none; + -webkit-user-select: none; +} + +.inner-popover { + @apply text-lsd-white fixed flex flex-col items-center gap-[25px] rounded-xl; + background: rgba(8, 5, 4, 0.9); + text-align: center; + padding: 3rem; + width: fit-content; + height: fit-content; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + cursor: default; +} + +.flyer-form { + @apply flex h-full flex-col items-center justify-between gap-[10px] p-[20px]; +} + +/* Scoped so these generic selectors don't bleed into the rest of the site */ +body:has(#board) { + footer { + @apply absolute right-0 bottom-0 left-0 m-[10px] flex flex-row justify-between; + } +} + +/* :where() zeroes out the scope's specificity so class-based overrides (e.g. .edit-button) win */ +:where(body:has(#board)) button { + all: unset; + @apply cursor-pointer; +} + +body:has(#board) .footer-btn { + @apply text-lsd-white rounded-md no-underline; + background: rgba(0, 0, 0, 0.55); + padding: 6px 14px; + font-size: 0.85rem; + backdrop-filter: blur(4px); + border: 1px solid rgba(255, 255, 255, 0.12); +} diff --git a/frontend/styles/dashboard.css b/frontend/styles/dashboard.css new file mode 100644 index 00000000..ffbf439a --- /dev/null +++ b/frontend/styles/dashboard.css @@ -0,0 +1,15 @@ +#dashboard { + @apply mx-auto flex max-w-2xl flex-col px-12 py-8; + + h1 { + @apply mb-6 text-3xl; + } + + .links { + @apply flex flex-col gap-4; + + .dashboard-link { + @apply bg-lsd-charcoal hover:bg-lsd-white hover:text-lsd-black rounded-lg p-4 text-center text-xl transition-colors duration-200; + } + } +} diff --git a/frontend/styles/error.css b/frontend/styles/error.css new file mode 100644 index 00000000..30ab19ee --- /dev/null +++ b/frontend/styles/error.css @@ -0,0 +1,21 @@ +#error { + h1 { + @apply mb-2 text-center text-2xl; + } + p { + @apply text-lsd-gray text-center; + } + a { + @apply decoration-lsd-blue hover:text-lsd-blue underline; + } +} + +#error-extra { + max-width: 800px; + .section { + @apply mb-6; + } + pre { + font-size: 10px; + } +} diff --git a/frontend/styles/events/attendees.css b/frontend/styles/events/attendees.css new file mode 100644 index 00000000..22f88393 --- /dev/null +++ b/frontend/styles/events/attendees.css @@ -0,0 +1,52 @@ +#events\/attendees { + header { + @apply flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between; + } + + .info { + @apply flex flex-col; + time, .stats { + @apply text-lsd-gray text-xs; + } + } + + .table-wrapper { + @apply overflow-x-scroll; + } + + table.attendees { + @apply w-full text-xs whitespace-nowrap; + border-spacing: 1rem 0; + border-collapse: separate; + } + + th:first-child, + td:first-child { + @apply pl-0; + } + + th:last-child, + td:last-child { + @apply pr-0; + } + + td.checkin time, + td.checkin button { + @apply align-middle; + } + + td.checkin time { + @apply mr-2; + } + + td.delete button { + @apply opacity-50 hover:opacity-100; + } + + .actions { + @apply flex flex-wrap gap-2 text-xs; + a { + @apply flex items-center gap-2 p-2 py-1; + } + } +} diff --git a/frontend/styles/events/attendees_add.css b/frontend/styles/events/attendees_add.css new file mode 100644 index 00000000..8c6633b8 --- /dev/null +++ b/frontend/styles/events/attendees_add.css @@ -0,0 +1,8 @@ +#events\/attendees\/add { + .actions { + @apply flex flex-wrap justify-between text-xs; + a { + @apply flex items-center gap-2 p-2 py-1; + } + } +} diff --git a/frontend/styles/events/edit.css b/frontend/styles/events/edit.css new file mode 100644 index 00000000..25b53591 --- /dev/null +++ b/frontend/styles/events/edit.css @@ -0,0 +1,57 @@ +#events\/edit { + .flyer { + .wrapper { + @apply relative; + #flyer { + @apply absolute h-full w-full cursor-pointer opacity-0; + } + .display { + @apply bg-lsd-black border-lsd-white/30 flex w-full items-center justify-between rounded-md border px-3 py-2; + } + #flyer-text { + @apply text-lsd-white/70; + } + #flyer-filename { + @apply text-lsd-white; + } + .ext\/button { + @apply px-2 py-0.5; + } + } + #flyer-preview { + @apply mx-auto mt-8 max-h-144 max-w-xs; + } + } + + #spots { + @apply mb-12 flex flex-col gap-16; + + .header { + @apply flex items-center gap-3; + } + .spot { + @apply flex flex-col gap-2; + } + .spot-row { + @apply flex gap-2; + + .field-group { + @apply flex grow flex-col gap-1; + + label { + @apply text-lsd-gray text-sm; + } + + input { + @apply text-center; + } + } + } + .rsvp-count { + @apply text-lsd-gray flex items-center text-sm; + } + textarea { + @apply h-32; + } + } +} diff --git a/frontend/styles/events/list.css b/frontend/styles/events/list.css new file mode 100644 index 00000000..9a8e8bd2 --- /dev/null +++ b/frontend/styles/events/list.css @@ -0,0 +1,26 @@ +#events\/list { + .event { + @apply mb-4 flex flex-col; + + .title { + @apply font-bold; + a { + @apply decoration-lsd-blue hover:text-lsd-blue underline; + } + } + + time { + @apply text-lsd-gray mb-0.5 text-xs; + } + .stats { + @apply text-lsd-gray mb-1 text-xs; + } + + .actions { + @apply flex flex-wrap gap-2 text-xs; + a { + @apply flex items-center gap-2 p-2 py-1; + } + } + } +} diff --git a/frontend/styles/events/rsvp_attendees.css b/frontend/styles/events/rsvp_attendees.css new file mode 100644 index 00000000..8f3c7ab4 --- /dev/null +++ b/frontend/styles/events/rsvp_attendees.css @@ -0,0 +1,74 @@ +#events\/rsvp\/attendees { + .attendee { + @apply mt-4 flex flex-col p-6; + + @apply border-lsd-charcoal rounded-lg border; + + &:has(input[name="is_me"]:checked) { + @apply border-lsd-white; + } + + div { + @apply flex items-center justify-between; + } + + .header { + .index { + @apply mb-2 text-2xl; + } + .is-me { + @apply flex items-center gap-2; + } + /*.is-me { + @apply mb-0 flex items-center gap-2 text-sm; + .radio { + @apply h-4 w-4; + } + span { + @apply select-none; + } + }*/ + } + + .subtext { + @apply text-lsd-gray mb-6 text-xl; + } + + .field { + @apply m-0 grow p-0; + } + .label { + @apply mb-2 p-0; + } + + .name { + @apply mb-4; + .first { + @apply mr-4; + } + } + + .email { + @apply mb-4; + } + + /*.fields { + @apply grid grid-cols-1 gap-4 md:grid-cols-2; + + .field { + @apply mb-0 pb-0; + .label { + @apply text-lsd-gray mb-1 text-sm; + } + } + + .email { + @apply md:col-span-2; + } + }*/ + } + + .actions { + @apply flex flex-row items-center justify-between; + } +} diff --git a/frontend/styles/events/rsvp_contribution.css b/frontend/styles/events/rsvp_contribution.css new file mode 100644 index 00000000..29df1b83 --- /dev/null +++ b/frontend/styles/events/rsvp_contribution.css @@ -0,0 +1,11 @@ +#events\/rsvp\/contribution { + @apply pt-0; + + .payment { + #stripe { + @apply rounded-md; + /* stripe elements is weird and doesn't work when using border-lsd-white/30 */ + border: 1px solid var(--color-lsd-white); + } + } +} diff --git a/frontend/styles/events/rsvp_guestlist.css b/frontend/styles/events/rsvp_guestlist.css new file mode 100644 index 00000000..ee1aab5e --- /dev/null +++ b/frontend/styles/events/rsvp_guestlist.css @@ -0,0 +1,5 @@ +#events\/rsvp\/guestlist { + .actions { + @apply flex flex-row items-center justify-between; + } +} diff --git a/frontend/styles/events/rsvp_layout.css b/frontend/styles/events/rsvp_layout.css new file mode 100644 index 00000000..f8627022 --- /dev/null +++ b/frontend/styles/events/rsvp_layout.css @@ -0,0 +1,170 @@ +#events\/rsvp\/nav { + @apply flex items-center px-6 py-4; + + @media (max-width: 768px) { + @apply bg-lsd-black sticky top-0; + } + + #back { + @apply cursor-pointer text-2xl; + } + .title { + @apply mx-auto overflow-hidden text-sm text-ellipsis whitespace-nowrap; + max-width: 80%; + } +} + +#events\/rsvp\/progress { + @apply bg-lsd-charcoal border-lsd-charcoal mb-6 flex w-full border border-b; + + @media (max-width: 768px) { + @apply sticky top-16; + } + + .tick { + @apply border-lsd-black h-2 grow border-l; + &:nth-child(1) { + @apply border-none; + } + &.filled { + @apply bg-lsd-white; + } + } +} + +.events\/rsvp\/layout { + @apply mx-auto w-full max-w-3xl px-6; + + @media (max-width: 768px) { + &:not(.nofloat) { + @apply mb-24; + } + } + + header { + h1 { + @apply text-2xl; + } + p { + @apply text-lsd-gray mt-1; + } + } +} + +#events\/rsvp\/actions { + @apply my-6 flex max-w-3xl; + + @media (max-width: 768px) { + &:not(.nofloat) { + @apply fixed right-0 bottom-4 left-0 z-1 mx-6 mb-0 max-w-3xl; + } + } + @media (min-width: 769px) { + @apply w-full; + } + + a, + button { + @apply bg-lsd-charcoal cursor-pointer touch-manipulation rounded-lg p-4; + } + + .share { + @apply mr-6; + } + + .submit { + @apply flex grow; + } + + .next { + @apply flex grow cursor-pointer justify-between; + #total { + @apply mr-1; + } + &.disabled { + opacity: 0.5; + } + } +} + +#events\/rsvp\/fade { + @apply hidden; + + @media (max-width: 768px) { + &:not(.nofloat) { + @apply fixed right-0 bottom-0 left-0 mx-6 flex h-32 w-full; + content: ""; + background: linear-gradient( + to bottom, + transparent 0%, + color-mix(in oklab, var(--color-lsd-black) 80%, transparent) + calc(100% - var(--spacing) * 16), + var(--color-lsd-black) calc(100% - var(--spacing) * 4) + ); + } + } +} + +#events\/rsvp\/summary { + @apply mb-6 border-t border-r border-l; + + .info { + @apply flex flex-col p-6 pb-0; + .title { + @apply text-2xl; + } + .line-items { + @apply mt-4 mb-2 flex flex-col; + .item { + @apply mb-4 flex flex-col; + .spot { + @apply mb-0.5 flex justify-between; + } + .email { + @apply text-lsd-gray text-xs; + } + } + } + .total { + @apply flex justify-between text-xl; + } + } + + .spacer { + aspect-ratio: 32; + @media (min-width: 768px) { + aspect-ratio: 48; + } + } + + .zigzag { + @apply relative h-6 w-full; + .stroke { + @apply bg-lsd-white absolute inset-0; + mask: conic-gradient( + from -45deg at bottom, + #0000, + #000 1deg 89deg, + #0000 90deg + ) + 50% / calc(100% / 16) 100%; + @media (min-width: 768px) { + mask-size: calc(100% / 32) 100%; + } + } + .fill { + @apply bg-lsd-black absolute inset-0; + mask: conic-gradient( + from -45deg at bottom, + #0000, + #000 1deg 89deg, + #0000 90deg + ) + 50% / calc(100% / 16) 100%; + @media (min-width: 768px) { + mask-size: calc(100% / 32) 100%; + } + transform: translateY(-1.5px); + } + } +} diff --git a/frontend/styles/events/rsvp_manage.css b/frontend/styles/events/rsvp_manage.css new file mode 100644 index 00000000..fee092a7 --- /dev/null +++ b/frontend/styles/events/rsvp_manage.css @@ -0,0 +1,61 @@ +#events\/rsvp\/manage\/flyer { + @apply relative w-full cursor-pointer overflow-hidden bg-cover bg-center; + + height: 240px; + transition: height 0.15s ease; + @media (min-width: 768px) { + @apply w-full max-w-3xl cursor-default; + height: 384px; + } + + img { + @apply block h-full w-full object-cover object-center; + } + &::after { + @apply pointer-events-none absolute w-full; + content: ""; + height: 140px; + top: calc(100% - 140px); + background: linear-gradient( + to bottom, + transparent 0%, + color-mix(in oklab, var(--color-lsd-black) 80%, transparent) 60%, + color-mix(in oklab, var(--color-lsd-black) 90%, transparent) 70%, + var(--color-lsd-black) 100% + ); + } +} + +#events\/rsvp\/manage { + @apply mx-auto w-full max-w-3xl px-6; + + header { + @apply mb-6; + h1 { + @apply mb-2 text-2xl italic; + } + p { + @apply text-lsd-gray text-sm leading-tight; + } + } + + .buttons { + @apply mb-6 flex flex-col gap-4; + form { + @apply flex; + } + a, + button { + @apply grow cursor-pointer rounded-lg py-4 text-center; + &.edit { + @apply bg-lsd-charcoal; + } + &.contact { + @apply border-lsd-charcoal border; + } + &.delete { + @apply border-lsd-red/30 bg-lsd-red/20; + } + } + } +} diff --git a/frontend/styles/events/rsvp_selection.css b/frontend/styles/events/rsvp_selection.css new file mode 100644 index 00000000..0c9c138f --- /dev/null +++ b/frontend/styles/events/rsvp_selection.css @@ -0,0 +1,74 @@ +#events\/rsvp\/selection { + .spot { + @apply mt-6 flex flex-col p-6; + + @apply border-lsd-white rounded-lg border; + + .name { + @apply mb-2 text-2xl; + } + .description { + @apply text-lsd-gray mb-4; + } + .split { + @apply flex justify-between; + .contribution { + @apply text-xl; + } + .quantity { + @apply flex; + .value { + @apply w-8 text-center select-none; + } + svg { + @apply cursor-pointer touch-manipulation select-none; + transition: color 0.05s linear; + &.disabled { + @apply cursor-default; + color: var(--color-lsd-charcoal); + } + } + } + } + } + + .range { + @apply grid grid-cols-[110px_auto] grid-rows-[40px]; + + .number { + @apply flex items-center; + label { + @apply my-0 mr-1 ml-0 text-lg; + } + input { + @apply mr-2 h-[30px]; + } + } + + .slider { + @apply relative; + input { + @apply relative z-10 px-0; + } + .stat { + @apply absolute; + left: calc( + 8px + (100% - 18px) * + ((var(--val) - var(--min)) / (var(--max) - var(--min))) + ); + + &::before { + @apply bg-lsd-green pointer-events-none absolute top-[4px] h-[30px] w-[2px] content-[""]; + } + + .label { + @apply absolute top-[34px] left-[6px] m-0 origin-top-left transform-[rotate(45deg)] text-sm select-none; + } + } + } + } + + .actions { + @apply flex flex-row items-center justify-between; + } +} diff --git a/frontend/styles/events/sessions.css b/frontend/styles/events/sessions.css new file mode 100644 index 00000000..d58eb564 --- /dev/null +++ b/frontend/styles/events/sessions.css @@ -0,0 +1,15 @@ +#events\/sessions { + table { + @apply w-full text-xs; + } + th, + td { + @apply px-2 py-1 text-left; + } + .rsvps { + @apply flex flex-col gap-1; + } + .rsvps span { + @apply text-neutral-400; + } +} diff --git a/frontend/styles/events/view.css b/frontend/styles/events/view.css new file mode 100644 index 00000000..d3239b29 --- /dev/null +++ b/frontend/styles/events/view.css @@ -0,0 +1,52 @@ +#events\/view { + @apply flex w-full flex-col items-center justify-center; + + .flyer { + @apply relative w-full bg-cover bg-center; + @media (min-width: 768px) { + @apply mt-6 max-w-lg; + } + img { + @apply block h-auto w-full; + } + &::after { + @apply pointer-events-none absolute w-full; + content: ""; + height: 140px; + top: calc(100% - 140px); + background: linear-gradient( + to bottom, + transparent 0%, + color-mix(in oklab, var(--color-lsd-black) 80%, transparent) 60%, + color-mix(in oklab, var(--color-lsd-black) 90%, transparent) 70%, + var(--color-lsd-black) 100% + ); + } + } + + .info { + @apply mx-auto w-full max-w-3xl px-6; + + @media (max-width: 768px) { + &:not(.nofloat) { + @apply mb-24; + } + } + + .title { + @apply mb-1.5 text-2xl; + } + + .details { + @apply text-lsd-gray mb-8 flex justify-between; + } + + .description { + @apply leading-relaxed; + } + } + + .rsvp { + @apply flex grow justify-between; + } +} diff --git a/frontend/styles/extensions/editor.css b/frontend/styles/extensions/editor.css new file mode 100644 index 00000000..6d43fd92 --- /dev/null +++ b/frontend/styles/extensions/editor.css @@ -0,0 +1,62 @@ +body:has(#ext\/editor) > header { + @apply hidden; +} + +#ext\/editor { + @apply w-full; +} + +#ext\/editor #form { + @apply relative flex h-full w-full flex-col; + + .navbar { + @apply sticky top-0 flex items-center justify-between gap-x-4 px-8 py-2; + @apply border-lsd-white/10 bg-lsd-black border-b; + + div { + @apply flex items-center gap-x-4; + } + } + + .split { + @apply grid min-h-screen w-full; + grid-template-columns: 1fr 1fr 1fr 1fr; + grid-template-rows: auto; + + .center { + @apply col-span-3 pt-4; + @apply flex w-full justify-center; + .editor { + @apply w-full max-w-3xl overflow-y-auto; + } + } + + .sidebar { + @apply col-span-1 px-4 pt-4; + @apply border-lsd-white/10 border-l; + } + } +} + +#ext\/editor .editor { + #actions { + @apply bg-lsd-black sticky top-0 mt-4 py-2.5; + @apply flex items-center gap-2.5; + + button { + @apply bg-lsd-white/10 hover:bg-lsd-white/20 h-8 w-10 p-1; + } + + button.active { + @apply bg-lsd-white/20; + } + } + #content { + @apply text-lg leading-relaxed; + @apply bg-lsd-white/5 grow overflow-y-auto p-8 focus:outline-none; + @apply mt-4; + a { + @apply decoration-lsd-blue underline; + } + } +} diff --git a/frontend/styles/extensions/form.css b/frontend/styles/extensions/form.css new file mode 100644 index 00000000..c2b10db6 --- /dev/null +++ b/frontend/styles/extensions/form.css @@ -0,0 +1,76 @@ +.ext\/form { + @apply flex flex-col; +} + +.ext\/form input, +.ext\/input { + @apply border-lsd-white/30 bg-lsd-black w-full rounded-md border px-3 py-2; + @apply focus:border-lsd-blue focus:outline-none; + &:disabled { + @apply cursor-not-allowed opacity-50; + } + &.error { + @apply border-lsd-red; + } + color-scheme: dark; + + &[type="radio"] { + @apply w-auto; + } +} + +.ext\/form label, +.ext\/label { + @apply mb-2 block; +} + +.ext\/form textarea, +.ext\/textarea { + @apply bg-lsd-black h-[50vh] w-full flex-1 resize-y p-2; + @apply border-lsd-white/30 border; +} + +.ext\/form select, +.ext\/select { + @apply border-lsd-white/40 bg-lsd-black rounded-md border px-3 py-2; + + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background: url('data:image/svg+xml;utf8,') + no-repeat right 0.5rem center; + + background-size: 1.5rem 1.5rem; +} + +.ext\/form .field, +.ext\/field { + @apply mb-4 flex flex-col pb-4; +} + +.ext\/form button, +.ext\/button { + @apply bg-lsd-white/10 hover:bg-lsd-white/15 block cursor-pointer px-4 py-2; + @apply border-lsd-white/10 border; + @apply rounded-sm; + + &.\:icon { + @apply px-3; + } + + &.\:green { + @apply border-lsd-green/30 bg-lsd-green/20 hover:bg-lsd-green/30; + } + + &.\:yellow { + @apply border-lsd-yellow/30 bg-lsd-yellow/20 hover:bg-lsd-yellow/30; + } + + &.\:red { + @apply border-lsd-red/30 bg-lsd-red/20 hover:bg-lsd-red/30; + } +} + +.ext\/button:disabled { + @apply cursor-not-allowed opacity-50; +} diff --git a/frontend/styles/extensions/layout.css b/frontend/styles/extensions/layout.css new file mode 100644 index 00000000..37bc86de --- /dev/null +++ b/frontend/styles/extensions/layout.css @@ -0,0 +1,35 @@ +.ext\/layout { + @apply mx-auto flex w-full flex-col px-6 py-4; + header { + @apply border-lsd-white/20 mb-4 flex items-center justify-between border-b pb-3; + h1 { + @apply text-3xl; + } + } +} +.ext\/layout.standard { + @apply max-w-2xl; +} +.ext\/layout.thin { + @apply max-w-xl px-12; +} + +.ext\/layout\/nav { + @apply border-lsd-white/10 flex w-full items-center justify-between border-b px-6 py-4; + #logo { + @apply text-lsd-bright font-extrabold; + } + .icons { + @apply flex items-center gap-6; + + a { + @apply flex items-center justify-center; + width: 24px; + height: 24px; + } + + .contact { + @apply text-2xl; + } + } +} diff --git a/frontend/styles/extensions/sidebar.css b/frontend/styles/extensions/sidebar.css new file mode 100644 index 00000000..eca536c9 --- /dev/null +++ b/frontend/styles/extensions/sidebar.css @@ -0,0 +1,3 @@ +#ext\/sidebar { + @apply flex; +} diff --git a/frontend/styles/home.css b/frontend/styles/home.css new file mode 100644 index 00000000..cbfad652 --- /dev/null +++ b/frontend/styles/home.css @@ -0,0 +1,123 @@ +@keyframes wave { + 0%, + 100% { + transform: translateY(4px); + } + 50% { + transform: translateY(-4px); + } +} + +#links { + .contact { + @apply text-xl; + } +} + +#home { + @apply p-6 md:p-16; + + .events { + @apply flex flex-col; + + @media (min-width: 768px) { + li { + @apply flex items-center; + } + li:last-child { + .title { + @apply pb-0; + } + } + + .event { + @apply grid; + grid-template-columns: 15ch 1px 1fr; + &:hover { + @apply text-lsd-gray; + .divider { + @apply bg-lsd-gray; + } + } + + .date { + @apply pt-0.5 font-mono text-base whitespace-nowrap tabular-nums; + grid-column: 1; + } + .divider { + grid-column: 2; + @apply bg-lsd-white h-full w-px; + } + .title { + @apply pb-2 pl-4 text-xl leading-snug; + grid-column: 3; + } + } + } + @media (max-width: 767px) { + .event { + @apply mb-6 flex flex-col; + .date { + @apply font-light; + } + .title { + @apply font-bold; + } + } + } + } + + .links { + @apply mb-6 flex items-center justify-between text-xs font-extrabold tracking-wider sm:text-xl md:justify-start md:text-2xl; + + .divider { + @apply border-lsd-white ml-6 h-4 w-6 border-l md:h-6; + } + + .newsletter { + span { + @apply inline-block; + transform: translateY(4px); + animation: wave 2s ease-in-out infinite; + } + span:nth-child(1) { + animation-delay: 0s; + } + span:nth-child(2) { + animation-delay: 0.1s; + } + span:nth-child(3) { + animation-delay: 0.2s; + } + span:nth-child(4) { + animation-delay: 0.3s; + } + span:nth-child(5) { + animation-delay: 0.5s; + } + span:nth-child(6) { + animation-delay: 0.6s; + } + span:nth-child(7) { + animation-delay: 0.7s; + } + span:nth-child(8) { + animation-delay: 0.8s; + } + span:nth-child(9) { + animation-delay: 0.9s; + } + span:nth-child(10) { + animation-delay: 1s; + } + } + } +} + +#suggestions { + @apply fixed right-0 bottom-0 p-2; + a { + @apply inline-block px-4 py-2; + @apply border-lsd-white/10 cursor-pointer rounded-sm border; + } +} diff --git a/frontend/styles/lists/edit.css b/frontend/styles/lists/edit.css new file mode 100644 index 00000000..68e8617c --- /dev/null +++ b/frontend/styles/lists/edit.css @@ -0,0 +1,18 @@ +#lists\/edit { + form { + .add-members { + @apply min-h-52; + } + .members { + label { + @apply border-lsd-white/30 mt-6 border-b pb-1 text-2xl; + } + li { + @apply mb-1 flex justify-between; + button { + @apply py-0.5 text-sm; + } + } + } + } +} diff --git a/frontend/styles/lists/list.css b/frontend/styles/lists/list.css new file mode 100644 index 00000000..8afb527e --- /dev/null +++ b/frontend/styles/lists/list.css @@ -0,0 +1,23 @@ +#lists\/list { + .list { + @apply mb-2 flex items-start justify-between; + + .title { + @apply mb-1 text-2xl font-bold; + a { + @apply decoration-lsd-blue hover:text-lsd-blue underline; + } + } + + .actions { + @apply flex; + a, + form { + @apply mr-2; + } + form { + @apply p-0; + } + } + } +} diff --git a/frontend/styles/main.css b/frontend/styles/main.css new file mode 100644 index 00000000..ac8f295e --- /dev/null +++ b/frontend/styles/main.css @@ -0,0 +1,88 @@ +@import "tailwindcss"; + +/** Extensions **/ +@import "./extensions/layout.css"; +@import "./extensions/form.css"; +@import "./extensions/sidebar.css"; +@import "./extensions/editor.css"; + +/* Home */ +@import "./home.css"; +@import "./dashboard.css"; +@import "./error.css"; +@import "./message.css"; +@import "./sublet.css"; + +/* Events */ +@import "./events/edit.css"; +@import "./events/list.css"; +@import "./events/view.css"; +@import "./events/attendees.css"; +@import "./events/attendees_add.css"; +@import "./events/rsvp_layout.css"; +@import "./events/rsvp_guestlist.css"; +@import "./events/rsvp_selection.css"; +@import "./events/rsvp_attendees.css"; +@import "./events/rsvp_contribution.css"; +@import "./events/rsvp_manage.css"; +@import "./events/sessions.css"; +/* Bulletin */ +@import "./bulletin/bulletin.css"; +@import "./bulletin/admin.css"; +/* Lists */ +@import "./lists/list.css"; +@import "./lists/edit.css"; +/* Posts */ +@import "./posts/edit.css"; +@import "./posts/list.css"; +@import "./posts/send.css"; +@import "./posts/view.css"; + +@theme { + --font-sans: system-ui; + /* CSS HEX */ + --color-lsd-bright: #fcf3ee; + --color-lsd-white: #ebe3de; + --color-lsd-gray: #878787; + --color-lsd-charcoal: #1e1b19; + --color-lsd-black: #080504; + + --color-lsd-blue: #8ec6ff; + --color-lsd-green: #6fd08c; + --color-lsd-red: #c34346; + --color-lsd-yellow: #eec643; +} + +/* + * TODO: Refactor styles into Tailwind utility classes + * Below styles are from page.tera.html + */ + +:root, +main { + @apply bg-lsd-black text-lsd-white flex w-full flex-col font-sans; +} + +main { + @apply items-center; +} + +body { + @apply min-h-full; +} + +blockquote { + @apply bg-lsd-black; + font-style: italic; + margin: 2rem 0; + padding: 1.5rem 2rem; + background: var(--color-lsd); + border-left: 4px solid #333; +} + +thead { + @apply border-b-lsd-gray border-b; +} +th { + @apply text-start; +} diff --git a/frontend/styles/message.css b/frontend/styles/message.css new file mode 100644 index 00000000..4a308b42 --- /dev/null +++ b/frontend/styles/message.css @@ -0,0 +1,11 @@ +#message { + h1 { + @apply mb-2 text-center text-2xl; + } + p { + @apply text-lsd-gray text-center; + } + a { + @apply decoration-lsd-blue hover:text-lsd-blue underline; + } +} diff --git a/frontend/styles/posts/edit.css b/frontend/styles/posts/edit.css new file mode 100644 index 00000000..a930123b --- /dev/null +++ b/frontend/styles/posts/edit.css @@ -0,0 +1,87 @@ +body:has(#posts\/edit) > header { + @apply hidden; +} + +#posts\/edit { + @apply w-full; +} + +#posts\/edit .editor { + @apply relative grid h-screen w-full; + grid-template-columns: 1fr 1fr 1fr auto; + + .navbar { + @apply sticky top-0 col-span-4 flex items-center py-2; + @apply border-lsd-white/10 gap-x-4 border-b px-8; + } + + .navbar .save { + @apply ml-auto flex items-center gap-x-4; + } + + .sidebar { + @apply sticky col-span-1 h-full w-full min-w-sm px-4 py-4; + @apply border-lsd-white/10 border-l; + } + + .content { + @apply col-span-3 w-full overflow-y-auto px-8; + } + + .content .pell-wrapper { + @apply min-h-screen w-full; + @apply flex flex-col items-center; + } +} + +#posts\/edit .editor .content .pell { + @apply relative w-full max-w-3xl overflow-visible; + padding-bottom: 50%; + + .pell-actionbar { + @apply bg-lsd-black sticky top-0 mt-4 py-2.5; + @apply flex items-center gap-2.5; + + .pell-button { + @apply bg-lsd-white/10 hover:bg-lsd-white/20 h-8 w-10 p-1; + } + + .pell-button-selected { + @apply bg-lsd-white/20; + } + + #status { + @apply ml-auto text-sm; + @apply before:mr-1.5 before:content-["•"]; + } + + #status.unsaved { + @apply text-amber-400; + } + + #status.error { + @apply text-red-500; + } + } + + .pell-content { + @apply text-lg leading-relaxed; + @apply bg-lsd-white/5 grow overflow-y-auto p-8 focus:outline-none; + @apply mt-4; + a { + @apply decoration-lsd-blue underline; + } + } +} + +#posts\/edit .editor .content .resize { + @apply absolute top-0 bottom-0 z-50 w-4 cursor-ew-resize bg-transparent; + + &.left { + @apply left-0; + } + + &.right { + @apply right-0; + } +} diff --git a/frontend/styles/posts/list.css b/frontend/styles/posts/list.css new file mode 100644 index 00000000..d0c910a6 --- /dev/null +++ b/frontend/styles/posts/list.css @@ -0,0 +1,27 @@ +#posts\/list { + .post { + @apply mb-6 flex items-start justify-between pb-6; + + .title { + @apply mb-1 text-2xl font-bold; + a { + @apply decoration-lsd-blue hover:text-lsd-blue underline; + } + } + + .info { + @apply mb-4; + } + + .actions { + @apply flex; + a, + form { + @apply mr-2; + } + form { + @apply p-0; + } + } + } +} diff --git a/frontend/styles/posts/send.css b/frontend/styles/posts/send.css new file mode 100644 index 00000000..884235a3 --- /dev/null +++ b/frontend/styles/posts/send.css @@ -0,0 +1,82 @@ +#posts\/send { + #send { + @apply mb-4; + } + + #progress { + @apply border-lsd-white/30 mb-8 rounded-md border px-6 py-4; + + .counts { + @apply mb-4 grid grid-cols-3 text-sm; + font-variant-numeric: tabular-nums; + li { + @apply flex flex-col items-center gap-0.5; + span { + @apply font-bold; + } + } + } + #status { + @apply mt-3 hidden text-center text-xl; + + &.ok { + @apply block text-[var(--color-lsd-green)]; + } + &.error { + @apply block text-[var(--color-lsd-red)]; + } + } + #errors { + @apply mt-4 hidden rounded border border-[var(--color-lsd-red)] bg-[var(--color-lsd-red)]/10 p-3 text-sm whitespace-pre-wrap text-[var(--color-lsd-red)]; + &.error { + @apply block; + } + } + + .bar-container { + @apply h-3 w-full overflow-hidden rounded bg-white/10; + #bar { + @apply relative h-full w-px overflow-hidden rounded bg-[var(--color-lsd-green)]/30 transition-[width] duration-200 ease-in-out; + &::before { + @apply absolute inset-0 left-0 w-[150%]; + content: ""; + background: linear-gradient( + 115deg, + transparent 0%, + color-mix(in srgb, var(--color-lsd-green) 80%, transparent) 45%, + color-mix(in srgb, var(--color-lsd-green) 80%, transparent) 55%, + transparent 100% + ); + transform: translateX(-100%); + animation: none; + } + &.sending { + &::before { + animation: shimmer 1.6s linear infinite; + } + } + &.ok { + @apply bg-[var(--color-lsd-green)]/80; + &::before { + animation: none; + } + } + &.error { + @apply bg-[var(--color-lsd-red)]/80; + &::before { + animation: none; + } + } + } + } + } +} + +@keyframes shimmer { + 80% { + transform: translateX(100%); + } + 100% { + transform: translateX(100%); + } +} diff --git a/frontend/styles/posts/view.css b/frontend/styles/posts/view.css new file mode 100644 index 00000000..3b4b3927 --- /dev/null +++ b/frontend/styles/posts/view.css @@ -0,0 +1,14 @@ +#posts\/view { + h1 { + @apply text-4xl font-bold; + } + article { + @apply mb-16 text-lg leading-relaxed; + p a { + @apply decoration-lsd-blue hover:text-lsd-blue underline; + } + p img { + @apply mx-auto; + } + } +} diff --git a/frontend/styles/sublet.css b/frontend/styles/sublet.css new file mode 100644 index 00000000..1b93456c --- /dev/null +++ b/frontend/styles/sublet.css @@ -0,0 +1,71 @@ +#sublet { + @apply mx-auto w-full max-w-3xl px-6 py-8; + + header { + @apply mb-8; + + h1 { + @apply mb-4 text-3xl font-bold tracking-tight md:text-4xl; + } + + .intro { + @apply mb-6 leading-relaxed; + } + } + + .apply { + @apply bg-lsd-charcoal inline-flex cursor-pointer items-center justify-between gap-4 rounded-lg px-6 py-4 transition-colors; + &:hover { + @apply bg-lsd-white/20; + } + } + + .content { + @apply flex flex-col gap-10; + } + + .section { + h2 { + @apply border-lsd-white/20 mb-4 border-b pb-2 text-xl font-bold tracking-wide; + } + + ul { + @apply flex flex-col gap-2; + } + + li { + @apply relative pl-5; + &::before { + @apply text-lsd-gray absolute left-0; + content: "\2022"; + } + } + } + + .photos { + @apply w-full; + + img { + @apply h-auto w-full rounded-sm; + } + + &.single { + img { + aspect-ratio: 16 / 10; + object-fit: cover; + } + } + + &.pair { + @apply grid grid-cols-2 gap-4; + img { + aspect-ratio: 4 / 3; + object-fit: cover; + } + } + } + + .cta { + @apply border-lsd-white/20 flex flex-col items-center gap-6 border-t pt-10 text-center; + } +} diff --git a/frontend/templates/auth/login.html b/frontend/templates/auth/login.html new file mode 100644 index 00000000..39a60b50 --- /dev/null +++ b/frontend/templates/auth/login.html @@ -0,0 +1,25 @@ +{% extends "layout.html" %} + +{% block title %}light and sound - login{% endblock title %} + +{% block content %} +
+
+

Login

+
+
+
+ + +
+
+ +
+ +
+
+{% endblock content %} diff --git a/frontend/templates/auth/login_email_sent.html b/frontend/templates/auth/login_email_sent.html new file mode 100644 index 00000000..b3f8ee20 --- /dev/null +++ b/frontend/templates/auth/login_email_sent.html @@ -0,0 +1,9 @@ +{% extends "message.html" %} + +{% block message %} +

Check your email!

+

+ An email with a link to login has been sent to {{ email }}. You can now + close this tab. +

+{% endblock message %} diff --git a/frontend/templates/bulletin/admin.html b/frontend/templates/bulletin/admin.html new file mode 100644 index 00000000..32ad2a13 --- /dev/null +++ b/frontend/templates/bulletin/admin.html @@ -0,0 +1,118 @@ +{% extends "layout.html" %} + +{% block title %}Bulletin Admin{% endblock title %} + +{% block scripts %} + +{% endblock scripts %} + +{% block content %} +
+ ← Bulletin +

Bulletin Admin

+ + + + + + + + + + + + + + + + {% for flyer in flyers %} + + + + + + + + + {% endfor %} + +
ImageNameUser IDLinkPositionActions
+ + {{ flyer.flyer_name|unwrap_or_empty }}{{ flyer.user_id }} + {{ flyer.x }}, {{ flyer.y }} / {{ flyer.rotation }}° + (Jump) + +
+ Edit +
+ + + + +
+
+ +
+ + +
+{% endblock content %} diff --git a/frontend/templates/bulletin/index.html b/frontend/templates/bulletin/index.html new file mode 100644 index 00000000..139609f2 --- /dev/null +++ b/frontend/templates/bulletin/index.html @@ -0,0 +1,171 @@ +{% extends "layout.html" %} + +{% block title %}LSD Bulletin{% endblock title %} + +{% block head %} + +{% endblock head %} + +{% block scripts %} + +{% endblock scripts %} + +{% block content %} +
+
+ {# Separate editable and non-editable flyers depending on user owner and role: admin #} + {% for flyer in read_only_flyers %} +
+ {% if let Some(url) = flyer.link_url %} + + + + {% else %} + + {% endif %} +
+ {% endfor %} + {% for flyer in editable_flyers %} +
+ + + + {% if let Some(url) = flyer.link_url %} + + + + {% else %} + + {% endif %} +
+ {% endfor %} + + {% if user.is_some() %} + + +
+
+
+

+ This bulletin board is free to use for anyone who has previously + attended an event at LSD. +
+
+ Feel free to post flyers for upcoming events, classes, workshops, + personal ads or anything else you think the community would be + interested in. +
+
+ Although you can place a flyer anywhere on the board, please be + considerate of others' space. In particular, don't cover up flyers + that others have posted unless the event on the flyer has already + passed. +
+
+ Be aware that this website is publicly accessible, and anyone with + the link can view flyers. Do not share anything you wouldn't want + a 🐷 or 🤖 to see. +

+
+
+ +
+
+
+ + + + + + +
+
+
+
+
+
+ + + + +
+
+
+
+ {% endif %} +
+
+ {% if user | has_role("admin") %} + Admin + {% else %} + + {% endif %} + +
+{% endblock content %} diff --git a/frontend/templates/contact/message_sent.html b/frontend/templates/contact/message_sent.html new file mode 100644 index 00000000..02081b4e --- /dev/null +++ b/frontend/templates/contact/message_sent.html @@ -0,0 +1,18 @@ +{% extends "layout.html" %} + +{% block title %} + Message Sent - light and sound +{% endblock title %} + +{% block content %} +
+
+

Suggestion Box

+
+

+ Your message has been sent. If you provided an email address, we will get + back to you as soon as possible. If you’ve contacted us with an event + proposal please allow 2-3 weeks for a response. +

+
+{% endblock content %} diff --git a/frontend/templates/contact/send.html b/frontend/templates/contact/send.html new file mode 100644 index 00000000..8ac89dfc --- /dev/null +++ b/frontend/templates/contact/send.html @@ -0,0 +1,72 @@ +{% extends "layout.html" %} + +{% block title %} + Suggestion Box - light and sound +{% endblock title %} + +{% block head %} + +{% endblock head %} + +{% block content %} +
+
+

Suggestion Box

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+{% endblock content %} + +{% block scripts %} + +{% endblock scripts %} diff --git a/frontend/templates/dashboard.html b/frontend/templates/dashboard.html new file mode 100644 index 00000000..88d9b846 --- /dev/null +++ b/frontend/templates/dashboard.html @@ -0,0 +1,16 @@ +{% extends "layout.html" %} + +{% block title %}light and sound - Dashboard{% endblock title %} + +{% block content %} +
+

Dashboard

+ +
+{% endblock content %} diff --git a/frontend/templates/editor.html b/frontend/templates/editor.html new file mode 100644 index 00000000..802d0125 --- /dev/null +++ b/frontend/templates/editor.html @@ -0,0 +1,342 @@ +{% extends "layout.html" %} + +{% block content %} +
+
+ +
+
+
+
+
+
+
+ +
+
+
+{% endblock content %} + +{% block scripts %} + + +{% endblock scripts %} diff --git a/frontend/templates/emails/already_unsubscribed.html b/frontend/templates/emails/already_unsubscribed.html new file mode 100644 index 00000000..83db2be2 --- /dev/null +++ b/frontend/templates/emails/already_unsubscribed.html @@ -0,0 +1,11 @@ +{% extends "../layout.html" %} +{% block title %}light and sound - Unsubscribe{% endblock title %} + +{% block content %} +
+
+

Unsubscribe

+
+

You have already been unsubscribed.

+
+{% endblock %} diff --git a/frontend/templates/emails/contact_us.html b/frontend/templates/emails/contact_us.html new file mode 100644 index 00000000..ac6de460 --- /dev/null +++ b/frontend/templates/emails/contact_us.html @@ -0,0 +1,9 @@ +{% extends "emails/layout.html" %} + +{% block title %}Contact us{% endblock %} + +{% block content %} +
+

{{ message }}

+
+{% endblock %} diff --git a/frontend/templates/emails/event_confirmation.html b/frontend/templates/emails/event_confirmation.html new file mode 100644 index 00000000..0ba62407 --- /dev/null +++ b/frontend/templates/emails/event_confirmation.html @@ -0,0 +1,70 @@ +{% extends "emails/layout.html" %} + +{% block title %}{% if let Some(subject) = event.confirmation_subject.as_deref() %} + {{ subject }} +{% else %} + You're confirmed for {{ event.title }} +{% endif %}{% endblock %} + +{% block content %} +
+ {% if let Some(flyer) = flyer %} + flyer + {% endif %} +

+ {% if let Some(subject) = event.confirmation_subject.as_deref() %} + {{ subject }} + {% else %} + You're confirmed for {{ event.title }} + {% endif %} +

+
+ +
+ + – + +
+
+ {% if let Some(confirmation_html) = event.confirmation_html %} + {{ confirmation_html | safe }} + {% else %} +

Thank you for your contribution. We look forward to seeing you!

+ {% endif %} + +
+{% endblock %} + +{% block styles %} + +{% endblock %} diff --git a/frontend/templates/emails/event_dayof.html b/frontend/templates/emails/event_dayof.html new file mode 100644 index 00000000..c350d775 --- /dev/null +++ b/frontend/templates/emails/event_dayof.html @@ -0,0 +1,48 @@ +{% extends "emails/layout.html" %} + +{% block title %}{% if let Some(subject) = event.dayof_subject.as_deref() %} + {{ subject }} +{% else %} + What to know for {{ event.title }} +{% endif %}{% endblock %} + +{% block content %} +
+ {% if let Some(flyer) = flyer %} + flyer + {% endif %} +

+ {% if let Some(subject) = event.dayof_subject.as_deref() %} + {{ subject }} + {% else %} + What to know for {{ event.title }} + {% endif %} +

+
+ +
+ + – + +
+
+ {{ event.dayof_html.as_ref().unwrap() | safe }} +
+{% endblock %} + +{% block styles %} + +{% endblock %} diff --git a/frontend/templates/emails/event_invite.html b/frontend/templates/emails/event_invite.html new file mode 100644 index 00000000..0252236d --- /dev/null +++ b/frontend/templates/emails/event_invite.html @@ -0,0 +1,70 @@ +{% extends "emails/layout.html" %} + +{% block title %}{% if let Some(subject) = event.invite_subject.as_deref() %} + {{ subject }} +{% else %} + Invitation to {{ event.title }} +{% endif %}{% endblock %} + +{% block content %} +
+ {% if let Some(flyer) = flyer %} + flyer + {% endif %} +

+ {% if let Some(subject) = event.invite_subject.as_deref() %} + {{ subject }} + {% else %} + Invitation to {{ event.title }} + {% endif %} +

+
+ +
+ + – + +
+
+ {% if let Some(invite_html) = event.invite_html %} + {{ invite_html | safe }} + {% endif %} +
+ RSVP Now +
+
+{% endblock %} + +{% block styles %} + +{% endblock %} diff --git a/frontend/templates/emails/layout.html b/frontend/templates/emails/layout.html new file mode 100644 index 00000000..9d05f268 --- /dev/null +++ b/frontend/templates/emails/layout.html @@ -0,0 +1,127 @@ + + + + + + {% block title %}{% endblock title %} + {# fix gmail inverting everything back to light #} + + + {% block styles %} + {% endblock styles %} + + {# fix gmail ignoring non-inline body styles #} + +
+ +
+
+ {% block content %} + {% endblock content %} + footer +
+
+ {% block footer %} + {% endblock footer %} +
+ + diff --git a/frontend/templates/emails/login.html b/frontend/templates/emails/login.html new file mode 100644 index 00000000..ab514b55 --- /dev/null +++ b/frontend/templates/emails/login.html @@ -0,0 +1,10 @@ +{% extends "emails/layout.html" %} + +{% block title %}Login to {{ "" | domain }}{% endblock %} + +{% block content %} + +{% endblock %} diff --git a/frontend/templates/emails/post.html b/frontend/templates/emails/post.html new file mode 100644 index 00000000..a07fe5f0 --- /dev/null +++ b/frontend/templates/emails/post.html @@ -0,0 +1,36 @@ +{% extends "emails/layout.html" %} + +{% block title %}{{ post.title }}{% endblock %} + +{% block content %} +
+

{{ post.title }}

+ + {{ post.content | safe }} +
+{% endblock %} + +{% block footer %} + {# gmail inexplicably deletes any nav elements so we use a div #} + +{% endblock %} + +{% block styles %} + +{% endblock %} diff --git a/frontend/templates/emails/unsubscribe.html b/frontend/templates/emails/unsubscribe.html new file mode 100644 index 00000000..81326c29 --- /dev/null +++ b/frontend/templates/emails/unsubscribe.html @@ -0,0 +1,14 @@ +{% extends "../layout.html" %} +{% block title %}light and sound - Unsubscribe{% endblock title %} + +{% block content %} +
+
+

Unsubscribe from {{ list.name }}

+
+

Are you sure you want to unsubscribe from this mailing list?

+
+ +
+
+{% endblock %} diff --git a/frontend/templates/error.html b/frontend/templates/error.html new file mode 100644 index 00000000..28fbbf2f --- /dev/null +++ b/frontend/templates/error.html @@ -0,0 +1,26 @@ +{% extends "layout.html" %} + +{% block title %}light and sound{% endblock title %} +{% block content %} +
+

{{ title }}

+

{{ message | safe }}

+
+ {% if context.is_some() || backtrace.is_some() %} +
+ {% if let Some(context) = context %} +
+

Context

+
{{ context }}
+
+ {% endif %} + + {% if let Some(backtrace) = backtrace %} +
+

Backtrace

+
{{ backtrace }}
+
+ {% endif %} +
+ {% endif %} +{% endblock content %} diff --git a/frontend/templates/error_simple.html b/frontend/templates/error_simple.html new file mode 100644 index 00000000..01277a23 --- /dev/null +++ b/frontend/templates/error_simple.html @@ -0,0 +1,9 @@ +{% extends "layout.html" %} + +{% block title %}light and sound{% endblock title %} +{% block content %} +
+

Error

+

{{ message | safe }}

+
+{% endblock content %} diff --git a/frontend/templates/events/attendees.html b/frontend/templates/events/attendees.html new file mode 100644 index 00000000..4bbb5f60 --- /dev/null +++ b/frontend/templates/events/attendees.html @@ -0,0 +1,217 @@ +{% extends "layout.html" %} +{% block title %}Attendees - {{ event.title }}{% endblock %} + +{% block content %} +
+
+
+

{{ event.title }}

+ + {{ rsvp_count }}/{{event.capacity}} RSVPed • ${{ total_contributions }} in contributions +
+
+ + Add attendee + + +
+
+
+ + + + + + + + + + + + + + {% for rsvp in rsvps %} + + + + + + + + + + {% endfor %} + +
NameGuest ofSpotCreatedCheckin
{{ rsvp.first_name }} {{ rsvp.last_name }}{{ rsvp.guest_of | unwrap_or_empty }} + {% if rsvp.is_manual %} + Added manually + {% else %} + {{ rsvp.spot_name | unwrap_or_empty }} + (${{ rsvp.contribution }}) + {% endif %} + + + + {% if let Some(checkin_at) = rsvp.checkin_at %} + + + {% else %} + + {% endif %} + + +
+
+
+ +{% endblock %} diff --git a/frontend/templates/events/attendees_add.html b/frontend/templates/events/attendees_add.html new file mode 100644 index 00000000..dda46f36 --- /dev/null +++ b/frontend/templates/events/attendees_add.html @@ -0,0 +1,48 @@ +{% extends "layout.html" %} + +{% block title %}Add Attendee - {{ event.title }}{% endblock title %} + +{% block content %} +
+
+

Add Attendee

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ ← Back + +
+
+
+{% endblock content %} diff --git a/frontend/templates/events/edit.html b/frontend/templates/events/edit.html new file mode 100644 index 00000000..6006f631 --- /dev/null +++ b/frontend/templates/events/edit.html @@ -0,0 +1,474 @@ +{% extends "layout.html" %} + +{% block title %}{{ event.title }}{% endblock title %} + +{% block content %} +
+
+

Edit Event

+
+
+
+ + +
+ +
+ + +
+ +
+ +
+ +
+
+ No flyer chosen + +
+ Choose File +
+
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+

Available Spots

+ +
+
+ + +
+
+ +{% endblock content %} diff --git a/frontend/templates/events/edit_confirmation.html b/frontend/templates/events/edit_confirmation.html new file mode 100644 index 00000000..b2fc59c1 --- /dev/null +++ b/frontend/templates/events/edit_confirmation.html @@ -0,0 +1,32 @@ +{% extends "editor.html" %} +{% block title %}Edit confirmation - {{ event.title }}{% endblock title %} + +{% block back %} + ← All Events +{% endblock back %} + +{% block form %} +
+ + +
+{% endblock form %} + +{% block actions %} + Preview +{% endblock actions %} diff --git a/frontend/templates/events/edit_dayof.html b/frontend/templates/events/edit_dayof.html new file mode 100644 index 00000000..4bd811aa --- /dev/null +++ b/frontend/templates/events/edit_dayof.html @@ -0,0 +1,46 @@ +{% extends "editor.html" %} +{% block title %}Edit Day-of - {{ event.title }}{% endblock title %} + +{% block back %} + ← All Events +{% endblock back %} + +{% block form %} +
+ + +
+{% endblock form %} + +{% block actions %} + Preview + +{% endblock actions %} + +{% block extra_scripts %} + +{% endblock extra_scripts %} diff --git a/frontend/templates/events/edit_description.html b/frontend/templates/events/edit_description.html new file mode 100644 index 00000000..e271c091 --- /dev/null +++ b/frontend/templates/events/edit_description.html @@ -0,0 +1,6 @@ +{% extends "editor.html" %} +{% block title %}Edit description - {{ event.title }}{% endblock title %} + +{% block back %} + ← All Events +{% endblock back %} diff --git a/frontend/templates/events/edit_invite.html b/frontend/templates/events/edit_invite.html new file mode 100644 index 00000000..52d49de2 --- /dev/null +++ b/frontend/templates/events/edit_invite.html @@ -0,0 +1,46 @@ +{% extends "editor.html" %} +{% block title %}Edit invite - {{ event.title }}{% endblock title %} + +{% block back %} + ← All Events +{% endblock back %} + +{% block form %} +
+ + +
+{% endblock form %} + +{% block actions %} + Preview + +{% endblock actions %} + +{% block extra_scripts %} + +{% endblock extra_scripts %} diff --git a/frontend/templates/events/list.html b/frontend/templates/events/list.html new file mode 100644 index 00000000..c0c01977 --- /dev/null +++ b/frontend/templates/events/list.html @@ -0,0 +1,140 @@ +{% extends "layout.html" %} + +{% block title %}light and sound - events{% endblock title %} + +{% block content %} +
+
+

Events

+ New Event +
+ {% for event in events %} +
+

+ {{ event.title }} +

+ + {{ event.rsvp_count }}/{{ event.capacity }} RSVPed • ${{ event.total_contributions }} in contributions +
+ + + + + Edit + + + + + + Description + + {% if event.guest_list_id.is_some() %} + + + + + Invite + + {% endif %} + + + + + Confirmation + + + + + + Day-of + + + + + + Attendees + +
+ +
+
+ +
+
+
+ {% endfor %} +
+{% endblock content %} diff --git a/frontend/templates/events/rsvp_attendees.html b/frontend/templates/events/rsvp_attendees.html new file mode 100644 index 00000000..0a5ee1af --- /dev/null +++ b/frontend/templates/events/rsvp_attendees.html @@ -0,0 +1,341 @@ +{% extends "events/rsvp_layout.html" %} + +{% block title %}light and sound - RSVP{% endblock title %} + +{% block progress %} + {% if mode == AttendeesMode::Create %} +
+
+
+ {% endif %} +{% endblock progress %} + +{% block back %} + {% if mode == AttendeesMode::Create %} + + + + {% else %} + + + + {% endif %} +{% endblock back %} + +{% block content %} +
+
+

Who will be attending?

+
+
+ {% for attendee in attendees %} +
+
+

Guest #{{ loop.index }}

+ {% if mode == AttendeesMode::Create && attendees.len() > 1 %} + + {% else if mode == AttendeesMode::Create && attendees.len() == 1 %} + + {% else if mode == AttendeesMode::Edit %} + + {% endif %} +
+
+

{{ attendee.spot_name }}

+

${{ attendee.contribution }}

+
+
+ + +
+
+ +
+
+ +
+
+ {% endfor %} +
+
+ {% if mode == AttendeesMode::Create %} + + + {% else %} + + {% endif %} +
+
+
+{% endblock content %} + +{% block scripts %} + +{% endblock scripts %} diff --git a/frontend/templates/events/rsvp_contribution.html b/frontend/templates/events/rsvp_contribution.html new file mode 100644 index 00000000..814762cd --- /dev/null +++ b/frontend/templates/events/rsvp_contribution.html @@ -0,0 +1,136 @@ +{% extends "events/rsvp_layout.html" %} +{% block head %} + {% if price > 0 %} + + {% endif %} +{% endblock head %} + +{% block title %}light and sound - RSVP{% endblock title %} + +{% block progress %} +
+
+
+{% endblock progress %} + +{% block back %} + + + +{% endblock back %} + +{% block content %} +
+
+
+

Summary

+
+ {% for rsvp in rsvps %} +
+
+
{{ rsvp.spot_name }}
+ ${{ rsvp.contribution }} +
+ +
+ {% endfor %} +
+
+

Total

+

${{ price }}

+
+
+
+
+
+
+
+
+ {% if price > 0 %} +
+
+
+ {% endif %} +
+ {% if price > 0 %} + + {% else %} +
+ +
+ {% endif %} +
+
+{% endblock content %} + +{% block scripts %} + {% if price > 0 %} + + {% endif %} +{% endblock scripts %} diff --git a/frontend/templates/events/rsvp_guestlist.html b/frontend/templates/events/rsvp_guestlist.html new file mode 100644 index 00000000..a3389a8d --- /dev/null +++ b/frontend/templates/events/rsvp_guestlist.html @@ -0,0 +1,28 @@ +{% extends "layout.html" %} + +{% block title %}light and sound - RSVP{% endblock title %} + +{% block content %} +
+
+

Are you on the list?

+
+
+
+ + +
+
+ ← Back + +
+
+
+{% endblock content %} diff --git a/frontend/templates/events/rsvp_layout.html b/frontend/templates/events/rsvp_layout.html new file mode 100644 index 00000000..e7f4e05b --- /dev/null +++ b/frontend/templates/events/rsvp_layout.html @@ -0,0 +1,13 @@ +{% extends "layout_no_nav.html" %} + +{% block nav %} + +
+ {% block progress %} + {% endblock progress %} +
+{% endblock nav %} diff --git a/frontend/templates/events/rsvp_manage.html b/frontend/templates/events/rsvp_manage.html new file mode 100644 index 00000000..9c0311eb --- /dev/null +++ b/frontend/templates/events/rsvp_manage.html @@ -0,0 +1,118 @@ +{% extends "layout.html" %} + +{% block title %}light and sound - Manage{% endblock title %} + +{% block preload %} + {% if let Some(flyer) = flyer %} + + {% endif %} +{% endblock %} + +{% block content %} + {% if let Some(flyer) = flyer %} +
+ event flyer +
+ {% endif %} +
+
+

Thank you.

+

+ Your contribution has been received. A confirmation email will be sent + to you{% if rsvps.len() > 1 %}and your guests{% endif %} shortly. +

+
+
+
+

Summary

+
+ {% for rsvp in rsvps %} +
+
+
{{ rsvp.spot_name }}
+ ${{ rsvp.contribution }} +
+ +
+ {% endfor %} +
+
+

Total

+

${{ price }}

+
+
+
+
+
+
+
+
+
+ {% if rsvps.len() > 1 %} + Edit guests + {% endif %} + Contact us + +
+
+{% endblock content %} + +{% block scripts %} + +{% endblock scripts %} diff --git a/frontend/templates/events/rsvp_selection.html b/frontend/templates/events/rsvp_selection.html new file mode 100644 index 00000000..fb773504 --- /dev/null +++ b/frontend/templates/events/rsvp_selection.html @@ -0,0 +1,233 @@ +{% extends "events/rsvp_layout.html" %} + +{% block title %}light and sound - RSVP{% endblock title %} + +{% block progress %} +
+
+
+{% endblock progress %} + +{% block back %} + + + +{% endblock back %} + +{% block content %} +
+
+

What will you contribute?

+
+
+ {% for spot in spots %} +
+

{{ spot.name }}

+

{{ spot.description }}

+
+
+ {% if spot.kind == "fixed" %} + ${{ spot.required_contribution.unwrap() }} + {% elif spot.kind == "variable" %} +
+
+ + +
+
+ + {% for stat in stats.stats.get(spot.id).into_iter().flatten() %} + + {{ stat.name }} + + {% endfor %} +
+
+ {% elif spot.kind == "free" || spot.kind == "work" %} + $0 + {% endif %} +
+
+ + + + {{ our_qtys.get(spot.id).unwrap_or(&0) }} + + + +
+
+
+ {% endfor %} +
+
+ + +
+
+
+{% endblock content %} + +{% block scripts %} + +{% endblock scripts %} diff --git a/frontend/templates/events/send_dayof.html b/frontend/templates/events/send_dayof.html new file mode 100644 index 00000000..dbbf2914 --- /dev/null +++ b/frontend/templates/events/send_dayof.html @@ -0,0 +1,172 @@ +{% extends "layout.html" %} +{% block title %}Send day-of info for {{ event.title }}{% endblock %} + +{% block content %} +
+
+

Send day-of info for {{ event.title }}

+ {% if let Some(sent_at) = event.dayof_sent_at %} +

+ Sent on {{ sent_at | format_datetime("%b %d at %l:%M %p") }}. New + RSVPs will receive this email along with their confirmation. +

+ {% endif %} +
+
+
+
    +
  • Sent0
  • +
  • Remaining0
  • +
  • ETA
  • +
+ +
+
+
+

+

+      
+ +
+
+{% endblock content %} + +{% block scripts %} + + +{% endblock scripts %} diff --git a/frontend/templates/events/send_invites.html b/frontend/templates/events/send_invites.html new file mode 100644 index 00000000..0bfabbf6 --- /dev/null +++ b/frontend/templates/events/send_invites.html @@ -0,0 +1,161 @@ +{% extends "layout.html" %} +{% block title %}Send invites to {{ event.title }}{% endblock %} + +{% block content %} +
+
+

Send invites to {{ event.title }}

+

Guestlist: {{ list.name }}

+
+
+
+
    +
  • Sent0
  • +
  • Remaining0
  • +
  • ETA
  • +
+ +
+
+
+

+

+      
+ +
+
+{% endblock content %} + +{% block scripts %} + + +{% endblock scripts %} diff --git a/frontend/templates/events/sessions.html b/frontend/templates/events/sessions.html new file mode 100644 index 00000000..f0dfcacd --- /dev/null +++ b/frontend/templates/events/sessions.html @@ -0,0 +1,68 @@ +{% extends "layout.html" %} +{% block title %}RSVP Sessions{% endblock %} + +{% block content %} +
+
+

RSVP Sessions

+
+ + + + + + + + + + + + + + + + {% for s in sessions %} + + + + + + + + + + + + {% endfor %} + +
EventReservation #StatusUserRSVPsCreatedUpdatedExpires
{{ s.event_title }}{{ s.token }}{{ s.status }}{{ s.user_email|unwrap_or_empty }} + {% for r in s.rsvps %} + {{ r.spot_name }} + ${{ r.contribution }}{% if let Some(email) = r.email.as_ref() %}({{ email }}){% endif %} + {% endfor %} + {{ s.created_at | format_datetime("%-I:%M%p") }}{{ s.updated_at | format_datetime("%-I:%M%p") }} + {% if s.status != "payment_pending" && s.status != "payment_confirmed" %}{% if s.expires_in > 0 %}{{ s.expires_in }}m{% else %}expired{% endif %}{% endif %} + + +
+
+ +{% endblock %} diff --git a/frontend/templates/events/view.html b/frontend/templates/events/view.html new file mode 100644 index 00000000..c28a352f --- /dev/null +++ b/frontend/templates/events/view.html @@ -0,0 +1,95 @@ +{% extends "layout.html" %} + +{% block title %}{{ event.title }}{% endblock title %} + +{% block preload %} + {% if let Some(flyer) = flyer %} + + {% endif %} +{% endblock %} + +{% block content %} +
+ {% if let Some(flyer) = flyer %} +
+ event flyer +
+ {% else %} +
+ {% endif %} +
+

{{ event.title }}

+
+ +
+ + - + +
+
+ {% if let Some(desc) = event.description_html.as_ref() %} +
{{ desc | safe }}
+ {% endif %} +
+
+ + {% if session.is_some() && (session.as_ref().unwrap().status == RsvpSession::PAYMENT_PENDING || session.as_ref().unwrap().status == RsvpSession::PAYMENT_CONFIRMED) %} + + Manage my reservation + + + {% else %} + + Attend & Contribute + + + {% endif %} +
+
+
+{% endblock content %} diff --git a/frontend/templates/home.html b/frontend/templates/home.html new file mode 100644 index 00000000..2bd6fbac --- /dev/null +++ b/frontend/templates/home.html @@ -0,0 +1,70 @@ +{% extends "layout.html" %} + +{% block title %}light and sound{% endblock title %} + +{% block content %} +
+ + + +
+{% endblock content %} + +{% block scripts %} + +{% endblock scripts %} diff --git a/frontend/templates/layout.html b/frontend/templates/layout.html new file mode 100644 index 00000000..1baa1fd6 --- /dev/null +++ b/frontend/templates/layout.html @@ -0,0 +1,50 @@ +{% extends "layout_no_nav.html" %} + +{% block nav %} + +{% endblock nav %} diff --git a/frontend/templates/layout_no_nav.html b/frontend/templates/layout_no_nav.html new file mode 100644 index 00000000..ed38a425 --- /dev/null +++ b/frontend/templates/layout_no_nav.html @@ -0,0 +1,25 @@ + + + + + {% block title %}light and sound design{% endblock title %} + {% block preload %} + {% endblock preload %} + + + {% block head %} + {% endblock head %} + + + {% block nav %} + {% endblock nav %} +
{% block content %}{% endblock content %}
+ {% block footer %} + {% endblock footer %} + {% block scripts %} + {% endblock scripts %} + {% block extra_scripts %} + {% endblock extra_scripts %} + {{ "" | livereload | safe }} + + diff --git a/frontend/templates/lists/confirmation.html b/frontend/templates/lists/confirmation.html new file mode 100644 index 00000000..92cd20cf --- /dev/null +++ b/frontend/templates/lists/confirmation.html @@ -0,0 +1,9 @@ +{% extends "message.html" %} + +{% block message %} +

Success!

+

+ You're now signed up for {{ list.description }} Please add {{ email }} to + your safe senders list. +

+{% endblock message %} diff --git a/frontend/templates/lists/edit.html b/frontend/templates/lists/edit.html new file mode 100644 index 00000000..1f04bfbb --- /dev/null +++ b/frontend/templates/lists/edit.html @@ -0,0 +1,68 @@ +{% extends "layout.html" %} +{% block title %}Edit list - {{ list.name }}{% endblock %} + +{% block content %} +
+
+

Edit List

+
+
+ {% if list.id != 0 %} + + {% endif %} +
+ + +
+
+ + +
+
+ + +
+ + {% if list.id != 0 %} +
+ +
    + {% for member in members %} +
  • + {% if let Some(first_name) = member.first_name %} + {% if let Some(last_name) = member.last_name %} + {{ member.email }} + ({{ first_name }} {{ last_name }}) + {% else %} + {{ member.email }} + {% endif %} + {% else %} + {{ member.email }} + {% endif %} + +
  • + {% endfor %} +
+
+ {% endif %} +
+
+{% endblock %} diff --git a/frontend/templates/lists/list.html b/frontend/templates/lists/list.html new file mode 100644 index 00000000..4eb60142 --- /dev/null +++ b/frontend/templates/lists/list.html @@ -0,0 +1,36 @@ +{% extends "layout.html" %} +{% block title %}light and sound - Lists{% endblock title %} + +{% block content %} +
+
+

Lists

+ New List +
+ {% for list in lists %} +
+
+

{{ list.name }}

+
+ +
+ {% endfor %} +
+{% endblock %} diff --git a/frontend/templates/lists/signup.html b/frontend/templates/lists/signup.html new file mode 100644 index 00000000..dc66da1d --- /dev/null +++ b/frontend/templates/lists/signup.html @@ -0,0 +1,19 @@ +{% extends "layout.html" %} + +{% block title %}light and sound - Sign Up{% endblock title %} + +{% block content %} +
+
+

Sign up for {{ list.description }}

+
+
+
+ + +
+ + +
+
+{% endblock content %} diff --git a/frontend/templates/message.html b/frontend/templates/message.html new file mode 100644 index 00000000..a7a454e7 --- /dev/null +++ b/frontend/templates/message.html @@ -0,0 +1,9 @@ +{% extends "layout.html" %} + +{% block title %}light and sound{% endblock title %} +{% block content %} +
+ {% block message %} + {% endblock message %} +
+{% endblock content %} diff --git a/frontend/templates/message_simple.html b/frontend/templates/message_simple.html new file mode 100644 index 00000000..a76defb6 --- /dev/null +++ b/frontend/templates/message_simple.html @@ -0,0 +1,9 @@ +{% extends "layout.html" %} + +{% block title %}light and sound{% endblock title %} +{% block content %} +
+

{{ title }}

+

{{ message | safe }}

+
+{% endblock content %} diff --git a/frontend/templates/posts/edit.html b/frontend/templates/posts/edit.html new file mode 100644 index 00000000..4d502df8 --- /dev/null +++ b/frontend/templates/posts/edit.html @@ -0,0 +1,55 @@ +{% extends "editor.html" %} +{% block title %}Edit post - {{ post.title }}{% endblock title %} + +{% block back %} + ← All Posts +{% endblock back %} + +{% block actions %} + +{% endblock actions %} + +{% block form %} +
+ + +
+
+ + +
+
+ + +
+{% endblock form %} + +{% block extra_scripts %} + +{% endblock extra_scripts %} diff --git a/frontend/templates/posts/list.html b/frontend/templates/posts/list.html new file mode 100644 index 00000000..7d4a33aa --- /dev/null +++ b/frontend/templates/posts/list.html @@ -0,0 +1,42 @@ +{% extends "layout.html" %} +{% block title %}light and sound{% endblock %} + +{% block content %} +
+
+

Posts

+ New Post +
+ {% for post in posts %} +
+
+

+ {{ post.title }} +

+ By {{ post.author }} • Updated + +
+ +
+ {% endfor %} +
+{% endblock content %} diff --git a/frontend/templates/posts/send.html b/frontend/templates/posts/send.html new file mode 100644 index 00000000..ab03e841 --- /dev/null +++ b/frontend/templates/posts/send.html @@ -0,0 +1,215 @@ +{% extends "layout.html" %} +{% block title %}Send post – {{ post.title }}{% endblock %} + +{% block content %} +
+
+

Send {{ post.title }}

+
+
+
+ + +
+
+
    +
  • Sent0
  • +
  • Remaining0
  • +
  • ETA
  • +
+ +
+
+
+

+

+      
+ + +
+
+{% endblock content %} + +{% block scripts %} + + +{% endblock scripts %} diff --git a/frontend/templates/posts/view.html b/frontend/templates/posts/view.html new file mode 100644 index 00000000..2e400d34 --- /dev/null +++ b/frontend/templates/posts/view.html @@ -0,0 +1,11 @@ +{% extends "layout.html" %} +{% block title %}{{ post.title }}{% endblock title %} + +{% block content %} +
+
+

{{ post.title }}

+
+
{{ post.content | safe }}
+
+{% endblock %} diff --git a/frontend/templates/sidebar.html b/frontend/templates/sidebar.html new file mode 100644 index 00000000..388ca465 --- /dev/null +++ b/frontend/templates/sidebar.html @@ -0,0 +1,10 @@ + diff --git a/frontend/templates/sublet.html b/frontend/templates/sublet.html new file mode 100644 index 00000000..766f6e2e --- /dev/null +++ b/frontend/templates/sublet.html @@ -0,0 +1,119 @@ +{% extends "layout.html" %} + +{% block title %}Studios for Sublet - light and sound{% endblock title %} + +{% block content %} +
+
+

Studios for Sublet

+

+ Flexible, light-filled studio spaces in the heart of Greenpoint. Ideal + for artists, photographers, sound designers, wellness practitioners, and + small creative teams. +

+ + Apply Now + + +
+ +
+
+

The Space

+
    +
  • Turnkey sublets or buildout
  • +
  • + Use as separate work areas or combine into a single larger studio +
  • +
  • Studios can be combined or modified within reason
  • +
  • Big south-facing windows with excellent natural light
  • +
  • Roof access
  • +
  • Work outside with skyline views
  • +
  • Plant plants
  • +
  • Make a (temporary, small) mess
  • +
  • Giant bathroom with utility sink
  • +
  • Shared kitchen & storage
  • +
  • 2nd-floor walk-up
  • +
+
+ +
+ Studio space +
+ +
+ Studio space +
+ +
+

The Building

+
    +
  • + Vibrant, active community of photographers, sound artists, lighting + designers, and other creatives +
  • +
  • Licensed massage therapists and acupuncturists onsite
  • +
  • + Free or reduced entry to weekly dinners, music, and dance events + upstairs +
  • +
+
+ +
+ Studio space +
+ +
+ Studio space +
+ +
+

The Location

+
    +
  • + 2 blocks from the Greenpoint G and Manhattan Ave restaurants and + cafes +
  • +
  • 5 minutes to the Newtown Creek Nature Walk
  • +
+
+ +
+ Studio space +
+ +
+ Studio space +
+ +
+

Availability & Pricing

+
    +
  • 9 studios available
  • +
  • Feb 1 move-in (some flex here)
  • +
  • $625–$2,850 per month, utilities included
  • +
+
+ +
+

+ If you're looking for a flexible & creative home base in Greenpoint, + this is it. +

+ + Apply Now + + +
+
+
+{% endblock content %} diff --git a/migrations/0000_drop_migrations.sql b/migrations/0000_drop_migrations.sql new file mode 100644 index 00000000..3f95094b --- /dev/null +++ b/migrations/0000_drop_migrations.sql @@ -0,0 +1,2 @@ +-- Drop the old custom migrations table in favor of the SQLx managed table +DROP TABLE IF EXISTS migrations; \ No newline at end of file diff --git a/migrations/0001_create_emails.down.sql b/migrations/0001_create_emails.down.sql new file mode 100644 index 00000000..bb5088d5 --- /dev/null +++ b/migrations/0001_create_emails.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS emails; \ No newline at end of file diff --git a/migrations/0001_create_emails.up.sql b/migrations/0001_create_emails.up.sql new file mode 100644 index 00000000..195740f2 --- /dev/null +++ b/migrations/0001_create_emails.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS emails ( + id INTEGER PRIMARY KEY NOT NULL, + kind TEXT NOT NULL, + address TEXT NOT NULL, + post_id INTEGER, + list_id INTEGER, + error TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + sent_at TIMESTAMP, + opened_at TIMESTAMP +); \ No newline at end of file diff --git a/migrations/0002_create_events.down.sql b/migrations/0002_create_events.down.sql new file mode 100644 index 00000000..bfd996dc --- /dev/null +++ b/migrations/0002_create_events.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS events; \ No newline at end of file diff --git a/migrations/0002_create_events.up.sql b/migrations/0002_create_events.up.sql new file mode 100644 index 00000000..9ff71f95 --- /dev/null +++ b/migrations/0002_create_events.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY NOT NULL, + title TEXT NOT NULL, + artist TEXT NOT NULL, + description TEXT NOT NULL, + start_date TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); \ No newline at end of file diff --git a/migrations/0003_create_lists.down.sql b/migrations/0003_create_lists.down.sql new file mode 100644 index 00000000..d9b740f0 --- /dev/null +++ b/migrations/0003_create_lists.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS lists; \ No newline at end of file diff --git a/migrations/0003_create_lists.up.sql b/migrations/0003_create_lists.up.sql new file mode 100644 index 00000000..d5278b91 --- /dev/null +++ b/migrations/0003_create_lists.up.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS lists ( + id INTEGER PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); \ No newline at end of file diff --git a/migrations/0004_create_list_members.down.sql b/migrations/0004_create_list_members.down.sql new file mode 100644 index 00000000..478b7b2c --- /dev/null +++ b/migrations/0004_create_list_members.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS list_members; \ No newline at end of file diff --git a/migrations/0004_create_list_members.up.sql b/migrations/0004_create_list_members.up.sql new file mode 100644 index 00000000..fe47caa3 --- /dev/null +++ b/migrations/0004_create_list_members.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS list_members ( + list_id INTEGER NOT NULL, + email TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (list_id, email) +); \ No newline at end of file diff --git a/migrations/0005_create_posts.down.sql b/migrations/0005_create_posts.down.sql new file mode 100644 index 00000000..52ac968d --- /dev/null +++ b/migrations/0005_create_posts.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS posts; \ No newline at end of file diff --git a/migrations/0005_create_posts.up.sql b/migrations/0005_create_posts.up.sql new file mode 100644 index 00000000..fa18fb7e --- /dev/null +++ b/migrations/0005_create_posts.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS posts ( + id INTEGER PRIMARY KEY NOT NULL, + title TEXT NOT NULL, + url TEXT NOT NULL, + author TEXT NOT NULL, + content TEXT NOT NULL, + content_rendered TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); \ No newline at end of file diff --git a/migrations/0006_create_login_tokens.down.sql b/migrations/0006_create_login_tokens.down.sql new file mode 100644 index 00000000..d2f607c5 --- /dev/null +++ b/migrations/0006_create_login_tokens.down.sql @@ -0,0 +1 @@ +-- Add down migration script here diff --git a/migrations/0006_create_login_tokens.up.sql b/migrations/0006_create_login_tokens.up.sql new file mode 100644 index 00000000..ba078b25 --- /dev/null +++ b/migrations/0006_create_login_tokens.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS login_tokens ( + id INTEGER PRIMARY KEY NOT NULL, + email TEXT NOT NULL, + token TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); \ No newline at end of file diff --git a/migrations/0007_create_session_tokens.down.sql b/migrations/0007_create_session_tokens.down.sql new file mode 100644 index 00000000..918799d3 --- /dev/null +++ b/migrations/0007_create_session_tokens.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS session_tokens; \ No newline at end of file diff --git a/migrations/0007_create_session_tokens.up.sql b/migrations/0007_create_session_tokens.up.sql new file mode 100644 index 00000000..796f0b4f --- /dev/null +++ b/migrations/0007_create_session_tokens.up.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS session_tokens ( + id INTEGER PRIMARY KEY NOT NULL, + user_id INTEGER NOT NULL, + token TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) +); \ No newline at end of file diff --git a/migrations/0008_create_users.down.sql b/migrations/0008_create_users.down.sql new file mode 100644 index 00000000..365a2107 --- /dev/null +++ b/migrations/0008_create_users.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS users; \ No newline at end of file diff --git a/migrations/0008_create_users.up.sql b/migrations/0008_create_users.up.sql new file mode 100644 index 00000000..f6d4b9ab --- /dev/null +++ b/migrations/0008_create_users.up.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY NOT NULL, + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + email TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); \ No newline at end of file diff --git a/migrations/0009_create_user_roles.down.sql b/migrations/0009_create_user_roles.down.sql new file mode 100644 index 00000000..a78eae2a --- /dev/null +++ b/migrations/0009_create_user_roles.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS user_roles; \ No newline at end of file diff --git a/migrations/0009_create_user_roles.up.sql b/migrations/0009_create_user_roles.up.sql new file mode 100644 index 00000000..ad6a07cc --- /dev/null +++ b/migrations/0009_create_user_roles.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS user_roles ( + user_id INTEGER NOT NULL, + role TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, role) +); \ No newline at end of file diff --git a/migrations/0010_remove_column_content_rendered_posts.down.sql b/migrations/0010_remove_column_content_rendered_posts.down.sql new file mode 100644 index 00000000..4b12b055 --- /dev/null +++ b/migrations/0010_remove_column_content_rendered_posts.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE posts ADD content_rendered TEXT NOT NULL; +UPDATE posts SET content_rendered = content; \ No newline at end of file diff --git a/migrations/0010_remove_column_content_rendered_posts.up.sql b/migrations/0010_remove_column_content_rendered_posts.up.sql new file mode 100644 index 00000000..5e6804b4 --- /dev/null +++ b/migrations/0010_remove_column_content_rendered_posts.up.sql @@ -0,0 +1,2 @@ +UPDATE posts SET content = content_rendered; +ALTER TABLE posts DROP COLUMN content_rendered; \ No newline at end of file diff --git a/migrations/0011_remove_users_name_nullability.down.sql b/migrations/0011_remove_users_name_nullability.down.sql new file mode 100644 index 00000000..553d34ae --- /dev/null +++ b/migrations/0011_remove_users_name_nullability.down.sql @@ -0,0 +1,12 @@ +PRAGMA writable_schema = ON; + +DELETE FROM users WHERE first_name IS NULL OR last_name IS NULL; + +UPDATE sqlite_master + SET sql = replace(sql, 'first_name TEXT', 'first_name TEXT NOT NULL') + WHERE tbl_name = 'users' AND type = 'table'; +UPDATE sqlite_master + SET sql = replace(sql, 'last_name TEXT', 'last_name TEXT NOT NULL') + WHERE tbl_name = 'users' AND type = 'table'; + +PRAGMA writable_schema = OFF; diff --git a/migrations/0011_remove_users_name_nullability.up.sql b/migrations/0011_remove_users_name_nullability.up.sql new file mode 100644 index 00000000..5529226d --- /dev/null +++ b/migrations/0011_remove_users_name_nullability.up.sql @@ -0,0 +1,10 @@ +PRAGMA writable_schema = ON; + +UPDATE sqlite_master + SET sql = replace(sql, 'first_name TEXT NOT NULL', 'first_name TEXT') + WHERE tbl_name = 'users' AND type = 'table'; +UPDATE sqlite_master + SET sql = replace(sql, 'last_name TEXT NOT NULL', 'last_name TEXT') + WHERE tbl_name = 'users' AND type = 'table'; + +PRAGMA writable_schema = OFF; diff --git a/migrations/0012_add_emails_index.down.sql b/migrations/0012_add_emails_index.down.sql new file mode 100644 index 00000000..751c7dd5 --- /dev/null +++ b/migrations/0012_add_emails_index.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS emails_post_list_address_unique; diff --git a/migrations/0012_add_emails_index.up.sql b/migrations/0012_add_emails_index.up.sql new file mode 100644 index 00000000..6c600884 --- /dev/null +++ b/migrations/0012_add_emails_index.up.sql @@ -0,0 +1,2 @@ +CREATE UNIQUE INDEX emails_post_list_address_unique +ON emails(address, post_id, list_id); diff --git a/migrations/0013_add_email_xrefs.down.sql b/migrations/0013_add_email_xrefs.down.sql new file mode 100644 index 00000000..9fe94300 --- /dev/null +++ b/migrations/0013_add_email_xrefs.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE emails DROP COLUMN user_id; +ALTER TABLE emails DROP COLUMN event_id; +ALTER TABLE emails DROP COLUMN notification_id; diff --git a/migrations/0013_add_email_xrefs.up.sql b/migrations/0013_add_email_xrefs.up.sql new file mode 100644 index 00000000..076afd25 --- /dev/null +++ b/migrations/0013_add_email_xrefs.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE emails ADD COLUMN user_id INTEGER; +ALTER TABLE emails ADD COLUMN event_id INTEGER; +ALTER TABLE emails ADD COLUMN notification_id INTEGER; diff --git a/migrations/0014_create_events_v2.down.sql b/migrations/0014_create_events_v2.down.sql new file mode 100644 index 00000000..0e1c9ea1 --- /dev/null +++ b/migrations/0014_create_events_v2.down.sql @@ -0,0 +1,12 @@ +DROP INDEX IF EXISTS events_slug_unique; +DROP TABLE IF EXISTS events; + +CREATE TABLE events ( + id INTEGER PRIMARY KEY NOT NULL, + title TEXT NOT NULL, + artist TEXT NOT NULL, + description TEXT NOT NULL, + start_date TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/migrations/0014_create_events_v2.up.sql b/migrations/0014_create_events_v2.up.sql new file mode 100644 index 00000000..4c46de0f --- /dev/null +++ b/migrations/0014_create_events_v2.up.sql @@ -0,0 +1,19 @@ +DROP TABLE IF EXISTS events; + +CREATE TABLE events ( + id INTEGER PRIMARY KEY NOT NULL, + title TEXT NOT NULL, + slug TEXT NOT NULL, + description TEXT NOT NULL, + + start TIMESTAMP NOT NULL, + end TIMESTAMP, + + capacity INTEGER NOT NULL, + unlisted BOOLEAN NOT NULL, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX IF NOT EXISTS events_slug_unique ON events(slug); diff --git a/migrations/0015_create_notifications.down.sql b/migrations/0015_create_notifications.down.sql new file mode 100644 index 00000000..9389dda5 --- /dev/null +++ b/migrations/0015_create_notifications.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS notifications; +DROP TABLE IF EXISTS event_notifications; diff --git a/migrations/0015_create_notifications.up.sql b/migrations/0015_create_notifications.up.sql new file mode 100644 index 00000000..349b0d72 --- /dev/null +++ b/migrations/0015_create_notifications.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS notifications ( + id INTEGER PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS event_notifications ( + event_id INTEGER NOT NULL, + notification_id INTEGER NOT NULL, + mins_before_start INTEGER NOT NULL, + PRIMARY KEY (event_id, notification_id) +); diff --git a/migrations/0016_create_transactions.down.sql b/migrations/0016_create_transactions.down.sql new file mode 100644 index 00000000..9033efef --- /dev/null +++ b/migrations/0016_create_transactions.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS transactions; diff --git a/migrations/0016_create_transactions.up.sql b/migrations/0016_create_transactions.up.sql new file mode 100644 index 00000000..f493c4a4 --- /dev/null +++ b/migrations/0016_create_transactions.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS transactions ( + id INTEGER PRIMARY KEY NOT NULL, + user_id INTEGER NOT NULL, + price INTEGER NOT NULL, + + refund_id INTEGER, + refunded_at TIMESTAMP, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/migrations/0017_create_spots.down.sql b/migrations/0017_create_spots.down.sql new file mode 100644 index 00000000..ba4d3c8a --- /dev/null +++ b/migrations/0017_create_spots.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS event_spots; +DROP TABLE IF EXISTS spots; diff --git a/migrations/0017_create_spots.up.sql b/migrations/0017_create_spots.up.sql new file mode 100644 index 00000000..dda96e3e --- /dev/null +++ b/migrations/0017_create_spots.up.sql @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS spots ( + id INTEGER PRIMARY KEY NOT NULL, + + name TEXT NOT NULL, + description TEXT NOT NULL, + qty_total INTEGER NOT NULL, + qty_per_person INTEGER NOT NULL, + kind TEXT NOT NULL, + sort INTEGER NOT NULL, + + -- kind = 'fixed' + required_contribution INTEGER, + -- kind = 'variable' + min_contribution INTEGER, + max_contribution INTEGER, + suggested_contribution INTEGER, + -- kind = 'work' + required_notice_hours INTEGER, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS event_spots ( + event_id INTEGER NOT NULL, + spot_id INTEGER NOT NULL, + PRIMARY KEY (event_id, spot_id) +); diff --git a/migrations/0018_create_rsvps.down.sql b/migrations/0018_create_rsvps.down.sql new file mode 100644 index 00000000..e125f39c --- /dev/null +++ b/migrations/0018_create_rsvps.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS rsvps; diff --git a/migrations/0018_create_rsvps.up.sql b/migrations/0018_create_rsvps.up.sql new file mode 100644 index 00000000..7aa6d9c4 --- /dev/null +++ b/migrations/0018_create_rsvps.up.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS rsvps ( + id INTEGER PRIMARY KEY NOT NULL, + event_id INTEGER NOT NULL, + spot_id INTEGER NOT NULL, + session_id INTEGER NOT NULL, + contribution INTEGER NOT NULL, + status TEXT NOT NULL, + + first_name TEXT, + last_name TEXT, + email TEXT, + user_id INTEGER, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + checkin_at TIMESTAMP +); diff --git a/migrations/0019_create_rsvp_sessions.down.sql b/migrations/0019_create_rsvp_sessions.down.sql new file mode 100644 index 00000000..56488583 --- /dev/null +++ b/migrations/0019_create_rsvp_sessions.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS rsvp_sessions; diff --git a/migrations/0019_create_rsvp_sessions.up.sql b/migrations/0019_create_rsvp_sessions.up.sql new file mode 100644 index 00000000..42436dc8 --- /dev/null +++ b/migrations/0019_create_rsvp_sessions.up.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS rsvp_sessions ( + id INTEGER PRIMARY KEY NOT NULL, + event_id INTEGER NOT NULL, + token TEXT NOT NULL, + status TEXT NOT NULL, + + first_name TEXT, + last_name TEXT, + email TEXT, + user_id INTEGER, + + stripe_client_secret TEXT, + stripe_payment_intent_id INTEGER, + stripe_charge_id INTEGER, + stripe_refund_id INTEGER, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/migrations/0020_rename_posts_url_slug.down.sql b/migrations/0020_rename_posts_url_slug.down.sql new file mode 100644 index 00000000..72763aae --- /dev/null +++ b/migrations/0020_rename_posts_url_slug.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE posts RENAME COLUMN slug TO url; +DROP INDEX IF EXISTS posts_slug_unique; diff --git a/migrations/0020_rename_posts_url_slug.up.sql b/migrations/0020_rename_posts_url_slug.up.sql new file mode 100644 index 00000000..1db4508b --- /dev/null +++ b/migrations/0020_rename_posts_url_slug.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE posts RENAME COLUMN url TO slug; +CREATE UNIQUE INDEX IF NOT EXISTS posts_slug_unique ON posts(slug); diff --git a/migrations/0021_readd_users_name_nullability.down.sql b/migrations/0021_readd_users_name_nullability.down.sql new file mode 100644 index 00000000..5529226d --- /dev/null +++ b/migrations/0021_readd_users_name_nullability.down.sql @@ -0,0 +1,10 @@ +PRAGMA writable_schema = ON; + +UPDATE sqlite_master + SET sql = replace(sql, 'first_name TEXT NOT NULL', 'first_name TEXT') + WHERE tbl_name = 'users' AND type = 'table'; +UPDATE sqlite_master + SET sql = replace(sql, 'last_name TEXT NOT NULL', 'last_name TEXT') + WHERE tbl_name = 'users' AND type = 'table'; + +PRAGMA writable_schema = OFF; diff --git a/migrations/0021_readd_users_name_nullability.up.sql b/migrations/0021_readd_users_name_nullability.up.sql new file mode 100644 index 00000000..553d34ae --- /dev/null +++ b/migrations/0021_readd_users_name_nullability.up.sql @@ -0,0 +1,12 @@ +PRAGMA writable_schema = ON; + +DELETE FROM users WHERE first_name IS NULL OR last_name IS NULL; + +UPDATE sqlite_master + SET sql = replace(sql, 'first_name TEXT', 'first_name TEXT NOT NULL') + WHERE tbl_name = 'users' AND type = 'table'; +UPDATE sqlite_master + SET sql = replace(sql, 'last_name TEXT', 'last_name TEXT NOT NULL') + WHERE tbl_name = 'users' AND type = 'table'; + +PRAGMA writable_schema = OFF; diff --git a/migrations/0022_create_event_flyers.down.sql b/migrations/0022_create_event_flyers.down.sql new file mode 100644 index 00000000..cc2aba5d --- /dev/null +++ b/migrations/0022_create_event_flyers.down.sql @@ -0,0 +1 @@ +DROP TABLE event_flyers; \ No newline at end of file diff --git a/migrations/0022_create_event_flyers.up.sql b/migrations/0022_create_event_flyers.up.sql new file mode 100644 index 00000000..8639f296 --- /dev/null +++ b/migrations/0022_create_event_flyers.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE event_flyers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id INTEGER NOT NULL, + image_full BLOB NOT NULL, + image_lg BLOB NOT NULL, + image_md BLOB NOT NULL, + image_sm BLOB NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (event_id) REFERENCES events (id) ON DELETE CASCADE, + UNIQUE (event_id) +); diff --git a/migrations/0023_add_rsvps_index_fk.down.sql b/migrations/0023_add_rsvps_index_fk.down.sql new file mode 100644 index 00000000..36777e71 --- /dev/null +++ b/migrations/0023_add_rsvps_index_fk.down.sql @@ -0,0 +1,19 @@ +ALTER TABLE rsvps RENAME TO rsvps_old; +CREATE TABLE rsvps ( + id INTEGER PRIMARY KEY NOT NULL, + event_id INTEGER NOT NULL, + spot_id INTEGER NOT NULL, + session_id INTEGER NOT NULL, + contribution INTEGER NOT NULL, + status TEXT NOT NULL, + first_name TEXT, + last_name TEXT, + email TEXT, + user_id INTEGER, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + checkin_at TIMESTAMP +); + +INSERT INTO rsvps SELECT * FROM rsvps_old; +DROP TABLE rsvps_old; diff --git a/migrations/0023_add_rsvps_index_fk.up.sql b/migrations/0023_add_rsvps_index_fk.up.sql new file mode 100644 index 00000000..4e23f133 --- /dev/null +++ b/migrations/0023_add_rsvps_index_fk.up.sql @@ -0,0 +1,22 @@ +ALTER TABLE rsvps RENAME TO rsvps_old; +CREATE TABLE rsvps ( + id INTEGER PRIMARY KEY NOT NULL, + event_id INTEGER NOT NULL, + spot_id INTEGER NOT NULL, + session_id INTEGER NOT NULL, + contribution INTEGER NOT NULL, + status TEXT NOT NULL, + first_name TEXT, + last_name TEXT, + email TEXT, + user_id INTEGER, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + checkin_at TIMESTAMP, + FOREIGN KEY (session_id) REFERENCES rsvp_sessions(id) ON DELETE CASCADE +); + +INSERT INTO rsvps SELECT * FROM rsvps_old; +DROP TABLE rsvps_old; + +CREATE INDEX rsvps_session_id ON rsvps(session_id); diff --git a/migrations/0024_add_events_guest_list.down.sql b/migrations/0024_add_events_guest_list.down.sql new file mode 100644 index 00000000..f0365770 --- /dev/null +++ b/migrations/0024_add_events_guest_list.down.sql @@ -0,0 +1,23 @@ +ALTER TABLE events RENAME TO events_old; +CREATE TABLE events ( + id INTEGER PRIMARY KEY NOT NULL, + title TEXT NOT NULL, + slug TEXT NOT NULL, + description TEXT NOT NULL, + + start TIMESTAMP NOT NULL, + end TIMESTAMP, + + capacity INTEGER NOT NULL, + unlisted BOOLEAN NOT NULL, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, +); + +INSERT INTO events (id, title, slug, description, start, end, capacity, unlisted, created_at, updated_at) + SELECT id, title, slug, description, start, end, capacity, unlisted, created_at, updated_at + FROM events_old; +DROP TABLE events_old; + +CREATE UNIQUE INDEX events_slug_unique ON events(slug); diff --git a/migrations/0024_add_events_guest_list.up.sql b/migrations/0024_add_events_guest_list.up.sql new file mode 100644 index 00000000..608cc022 --- /dev/null +++ b/migrations/0024_add_events_guest_list.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE events + ADD COLUMN guest_list_id INTEGER + REFERENCES lists(id) ON DELETE SET NULL; diff --git a/migrations/0025_add_users_email_index.down.sql b/migrations/0025_add_users_email_index.down.sql new file mode 100644 index 00000000..7feeb12b --- /dev/null +++ b/migrations/0025_add_users_email_index.down.sql @@ -0,0 +1 @@ +DROP INDEX users_email_unique; diff --git a/migrations/0025_add_users_email_index.up.sql b/migrations/0025_add_users_email_index.up.sql new file mode 100644 index 00000000..0ae2b157 --- /dev/null +++ b/migrations/0025_add_users_email_index.up.sql @@ -0,0 +1 @@ +CREATE UNIQUE INDEX users_email_unique ON users(email); diff --git a/migrations/0026_add_list_members_user_id.down.sql b/migrations/0026_add_list_members_user_id.down.sql new file mode 100644 index 00000000..b9f027ec --- /dev/null +++ b/migrations/0026_add_list_members_user_id.down.sql @@ -0,0 +1,12 @@ +ALTER TABLE list_members RENAME TO list_members_old; + +CREATE TABLE list_members ( + list_id INTEGER NOT NULL, + email TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (list_id, email) +); + +INSERT INTO list_members (list_id, email, created_at) + SELECT list_id, email, created_at + FROM list_members_old; diff --git a/migrations/0026_add_list_members_user_id.up.sql b/migrations/0026_add_list_members_user_id.up.sql new file mode 100644 index 00000000..cefda725 --- /dev/null +++ b/migrations/0026_add_list_members_user_id.up.sql @@ -0,0 +1,16 @@ +ALTER TABLE list_members RENAME TO list_members_old; +CREATE TABLE list_members ( + list_id INTEGER NOT NULL, + email TEXT NOT NULL, + user_id INTEGER, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (list_id, email), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +INSERT INTO list_members (list_id, email, created_at) + SELECT list_id, email, created_at + FROM list_members_old; +DROP TABLE list_members_old; + +CREATE INDEX list_members_user_id ON list_members(user_id); diff --git a/migrations/0027_add_flyer_size.down.sql b/migrations/0027_add_flyer_size.down.sql new file mode 100644 index 00000000..4d909282 --- /dev/null +++ b/migrations/0027_add_flyer_size.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE event_flyers DROP COLUMN width; +ALTER TABLE event_flyers DROP COLUMN height; +ALTER TABLE event_flyers DROP COLUMN image_thumb; diff --git a/migrations/0027_add_flyer_size.up.sql b/migrations/0027_add_flyer_size.up.sql new file mode 100644 index 00000000..bc976f20 --- /dev/null +++ b/migrations/0027_add_flyer_size.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE event_flyers ADD COLUMN width INTEGER NOT NULL DEFAULT 0; +ALTER TABLE event_flyers ADD COLUMN height INTEGER NOT NULL DEFAULT 0; +ALTER TABLE event_flyers ADD COLUMN image_thumb BLOB NOT NULL DEFAULT X''; diff --git a/migrations/0028_alter_emails_index_nonunique.down.sql b/migrations/0028_alter_emails_index_nonunique.down.sql new file mode 100644 index 00000000..a4f61ed2 --- /dev/null +++ b/migrations/0028_alter_emails_index_nonunique.down.sql @@ -0,0 +1,4 @@ +DROP INDEX emails_post_list_address; + +CREATE UNIQUE INDEX emails_post_list_address_unique +ON emails(address, post_id, list_id); diff --git a/migrations/0028_alter_emails_index_nonunique.up.sql b/migrations/0028_alter_emails_index_nonunique.up.sql new file mode 100644 index 00000000..136cbdd8 --- /dev/null +++ b/migrations/0028_alter_emails_index_nonunique.up.sql @@ -0,0 +1,4 @@ +DROP INDEX emails_post_list_address_unique; + +CREATE INDEX emails_post_list_address +ON emails(address, post_id, list_id); diff --git a/migrations/0029_remove_fks.sql b/migrations/0029_remove_fks.sql new file mode 100644 index 00000000..d9f10df2 --- /dev/null +++ b/migrations/0029_remove_fks.sql @@ -0,0 +1,60 @@ +-- list_members +ALTER TABLE list_members RENAME TO list_members_old; +CREATE TABLE list_members ( + list_id INTEGER NOT NULL, + email TEXT NOT NULL, + user_id INTEGER, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (list_id, email) +); +INSERT INTO list_members SELECT * FROM list_members_old; +DROP TABLE list_members_old; + +-- session_tokens +ALTER TABLE session_tokens RENAME TO session_tokens_old; +CREATE TABLE session_tokens ( + id INTEGER PRIMARY KEY NOT NULL, + user_id INTEGER NOT NULL, + token TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); +INSERT INTO session_tokens SELECT * FROM session_tokens_old; +DROP TABLE session_tokens_old; + +-- rsvps +ALTER TABLE rsvps RENAME TO rsvps_old; +CREATE TABLE rsvps ( + id INTEGER PRIMARY KEY NOT NULL, + event_id INTEGER NOT NULL, + spot_id INTEGER NOT NULL, + session_id INTEGER NOT NULL, + contribution INTEGER NOT NULL, + status TEXT NOT NULL, + first_name TEXT, + last_name TEXT, + email TEXT, + user_id INTEGER, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + checkin_at TIMESTAMP +); +INSERT INTO rsvps SELECT * FROM rsvps_old; +DROP TABLE rsvps_old; + +ALTER TABLE event_flyers RENAME TO event_flyers_old; +CREATE TABLE event_flyers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id INTEGER NOT NULL, + width INTEGER NOT NULL DEFAULT 0, + height INTEGER NOT NULL DEFAULT 0, + image_full BLOB NOT NULL, + image_lg BLOB NOT NULL, + image_md BLOB NOT NULL, + image_sm BLOB NOT NULL, + image_thumb BLOB NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE (event_id) +); +INSERT INTO event_flyers SELECT * FROM event_flyers_old; +DROP TABLE event_flyers_old; diff --git a/migrations/0030_create_users_v2.sql b/migrations/0030_create_users_v2.sql new file mode 100644 index 00000000..a7eb599e --- /dev/null +++ b/migrations/0030_create_users_v2.sql @@ -0,0 +1,76 @@ +PRAGMA foreign_keys = OFF; + +-- New users table +ALTER TABLE users RENAME TO users_old; +CREATE TABLE users ( + id INTEGER PRIMARY KEY NOT NULL, + + email TEXT NOT NULL, + first_name TEXT, + last_name TEXT, + phone TEXT, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO users (id, email, first_name, last_name, created_at, updated_at) + SELECT id, email, first_name, last_name, created_at, created_at + FROM users_old; +DROP TABLE users_old; + +CREATE UNIQUE INDEX users_email_unique ON users(email); + + +-- Fix list_members to just reference user_id with no fk +ALTER TABLE list_members RENAME TO list_members_old; +CREATE TABLE list_members ( + list_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (list_id, user_id) +); +-- Populate old list_members to users +INSERT INTO users (email, created_at, updated_at) + SELECT DISTINCT lm.email, lm.created_at, lm.created_at FROM list_members_old lm + WHERE NOT EXISTS ( + SELECT 1 FROM users u + WHERE u.email = lm.email + ); +-- Create new list_members +INSERT INTO list_members (list_id, user_id, created_at) + SELECT lm.list_id, u.id, lm.created_at + FROM list_members_old lm + JOIN users u ON u.email = lm.email; +DROP TABLE list_members_old; + + +-- New user_history table +CREATE TABLE user_history ( + user_id INTEGER NOT NULL, + version INTEGER NOT NULL, + + email TEXT NOT NULL, + first_name TEXT, + last_name TEXT, + phone TEXT, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, version) +); +-- Populate initial history +INSERT INTO user_history (user_id, version, email, first_name, last_name, phone, created_at) + SELECT id, 0, email, first_name, last_name, phone, created_at + FROM users; + + +-- New user_attrs table +CREATE TABLE user_attrs ( + user_id INTEGER PRIMARY KEY NOT NULL, + rsvp_guests u64 NOT NULL DEFAULT 0, + rsvp_credits u64 NOT NULL DEFAULT 0, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- Populate initial attrs +INSERT INTO user_attrs (user_id, updated_at) + SELECT id, created_at FROM users; diff --git a/migrations/0032_create_rsvps_v2.sql b/migrations/0032_create_rsvps_v2.sql new file mode 100644 index 00000000..db8a8127 --- /dev/null +++ b/migrations/0032_create_rsvps_v2.sql @@ -0,0 +1,34 @@ +DROP TABLE rsvps; +DROP TABLE rsvp_sessions; + +CREATE TABLE rsvps ( + id INTEGER PRIMARY KEY NOT NULL, + session_id INTEGER NOT NULL, + + spot_id INTEGER NOT NULL, + contribution INTEGER NOT NULL, + user_id INTEGER, + user_version INTEGER, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + checkin_at TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS rsvp_sessions ( + id INTEGER PRIMARY KEY NOT NULL, + event_id INTEGER NOT NULL, + token TEXT NOT NULL, + status TEXT NOT NULL, + + user_id INTEGER, + user_version INTEGER, + + stripe_client_secret TEXT, + stripe_payment_intent_id INTEGER, + stripe_charge_id INTEGER, + stripe_refund_id INTEGER, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/migrations/0033_create_emails_v2.sql b/migrations/0033_create_emails_v2.sql new file mode 100644 index 00000000..f152d164 --- /dev/null +++ b/migrations/0033_create_emails_v2.sql @@ -0,0 +1,27 @@ +DROP INDEX emails_post_list_address; + +ALTER TABLE emails RENAME TO emails_old; +CREATE TABLE IF NOT EXISTS emails ( + id INTEGER PRIMARY KEY NOT NULL, + kind TEXT NOT NULL, + user_id INTEGER NOT NULL, + user_version INTEGER NOT NULL, + + post_id INTEGER, + list_id INTEGER, + event_id INTEGER, + notification_id INTEGER, + + error TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + sent_at TIMESTAMP, + opened_at TIMESTAMP +); + +INSERT INTO emails (id, kind, user_id, user_version, list_id, post_id, event_id, notification_id, error, created_at, sent_at, opened_at) + SELECT e.id, e.kind, u.id, 0, e.list_id, e.post_id, e.event_id, e.notification_id, e.error, e.created_at, e.sent_at, e.opened_at + FROM emails_old e + JOIN users u ON u.email = e.address; + +CREATE INDEX emails_post_list_address +ON emails(user_id, post_id, list_id); diff --git a/migrations/0034_create_auth_v2.sql b/migrations/0034_create_auth_v2.sql new file mode 100644 index 00000000..6eac59a3 --- /dev/null +++ b/migrations/0034_create_auth_v2.sql @@ -0,0 +1,21 @@ +DROP TABLE login_tokens; +CREATE TABLE login_tokens ( + user_id INTEGER PRIMARY KEY NOT NULL, + token TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + used_at TIMESTAMP +); + +ALTER TABLE session_tokens RENAME TO session_tokens_old; +CREATE TABLE session_tokens ( + user_id INTEGER NOT NULL, + token TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX session_tokens_token +ON session_tokens(token); + +INSERT INTO session_tokens (user_id, token, created_at) + SELECT user_id, token, created_at + FROM session_tokens_old; +DROP TABLE session_tokens_old; diff --git a/migrations/0035_create_events_v3.sql b/migrations/0035_create_events_v3.sql new file mode 100644 index 00000000..5f014a78 --- /dev/null +++ b/migrations/0035_create_events_v3.sql @@ -0,0 +1,30 @@ +ALTER TABLE events RENAME TO events_old; +CREATE TABLE events ( + id INTEGER PRIMARY KEY NOT NULL, + title TEXT NOT NULL, + slug TEXT NOT NULL, + description TEXT NOT NULL, + start TIMESTAMP NOT NULL, + end TIMESTAMP, + capacity INTEGER NOT NULL, + unlisted BOOLEAN NOT NULL, + guest_list_id INTEGER, + + invite_html TEXT, + invite_updated_at TIMESTAMP, + invite_sent_at TIMESTAMP, + + confirmation_html TEXT, + confirmation_updated_at TIMESTAMP, + + dayof_html TEXT, + dayof_updated_at TIMESTAMP, + dayof_sent_at TIMESTAMP, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO events (id, title, slug, description, start, end, capacity, unlisted, created_at, updated_at) + SELECT id, title, slug, description, start, end, capacity, unlisted, created_at, updated_at + FROM events_old; diff --git a/migrations/0036_create_events_v4.sql b/migrations/0036_create_events_v4.sql new file mode 100644 index 00000000..f8bb6ff2 --- /dev/null +++ b/migrations/0036_create_events_v4.sql @@ -0,0 +1,40 @@ +DROP TABLE IF EXISTS events_old; +ALTER TABLE events RENAME TO events_old; +CREATE TABLE events ( + id INTEGER PRIMARY KEY NOT NULL, + title TEXT NOT NULL, + slug TEXT NOT NULL, + start TIMESTAMP NOT NULL, + end TIMESTAMP, + capacity INTEGER NOT NULL, + unlisted BOOLEAN NOT NULL, + closed BOOLEAN NOT NULL DEFAULT FALSE, + guest_list_id INTEGER, + spots_per_person INTEGER, + + description_html TEXT, + description_updated_at TIMESTAMP, + + invite_subject TEXT, + invite_html TEXT, + invite_updated_at TIMESTAMP, + invite_sent_at TIMESTAMP, + + confirmation_subject TEXT, + confirmation_html TEXT, + confirmation_updated_at TIMESTAMP, + + dayof_subject TEXT, + dayof_html TEXT, + dayof_updated_at TIMESTAMP, + dayof_sent_at TIMESTAMP, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO events (id, title, slug, start, end, capacity, unlisted, closed, guest_list_id, description_html, invite_html, invite_updated_at, invite_sent_at, confirmation_html, confirmation_updated_at, dayof_html, dayof_updated_at, dayof_sent_at, created_at, updated_at) + SELECT id, title, slug, start, end, capacity, unlisted, FALSE, guest_list_id, description, invite_html, invite_updated_at, invite_sent_at, confirmation_html, confirmation_updated_at, dayof_html, dayof_updated_at, dayof_sent_at, created_at, updated_at + FROM events_old; + +DROP TABLE events_old; diff --git a/migrations/0037_create_manual_rsvps.sql b/migrations/0037_create_manual_rsvps.sql new file mode 100644 index 00000000..8e62dfa8 --- /dev/null +++ b/migrations/0037_create_manual_rsvps.sql @@ -0,0 +1,10 @@ +-- Manual RSVPs for admin-added attendees (without checkout flow) +CREATE TABLE manual_rsvps ( + event_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + creator_user_id INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + checkin_at TIMESTAMP, + PRIMARY KEY (event_id, user_id) +); diff --git a/migrations/0038_create_flyers.down.sql b/migrations/0038_create_flyers.down.sql new file mode 100644 index 00000000..cb8dde9c --- /dev/null +++ b/migrations/0038_create_flyers.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS flyers; diff --git a/migrations/0038_create_flyers.up.sql b/migrations/0038_create_flyers.up.sql new file mode 100644 index 00000000..4129ecf3 --- /dev/null +++ b/migrations/0038_create_flyers.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS flyers ( + id INTEGER PRIMARY KEY NOT NULL, + user_id INTEGER NOT NULL, + x INTEGER NOT NULL, + y INTEGER NOT NULL, + rotation INTEGER NOT NULL DEFAULT 0, + image_data BLOB NOT NULL DEFAULT X'', + image_version INTEGER NOT NULL DEFAULT 1, + link_url TEXT, + flyer_name TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) +); diff --git a/mise.toml b/mise.toml new file mode 100644 index 00000000..7b931342 --- /dev/null +++ b/mise.toml @@ -0,0 +1,3 @@ +[tools] +node = "latest" +rust = "latest" diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..44f2b0be --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3293 @@ +{ + "name": "lsd", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "easing-utils": "^1.0.0" + }, + "devDependencies": { + "@tailwindcss/cli": "^4.1.2", + "livereload": "^0.9.3", + "npm-run-all": "^4.1.5", + "prettier": "^3.5.3", + "prettier-plugin-jinja-template": "^2.0.0", + "prettier-plugin-tailwindcss": "^0.6.11", + "typescript": "^5.8.3" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/@tailwindcss/cli": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.1.3.tgz", + "integrity": "sha512-irQW1LhBCi8O7OPrDVTyo6IZFqUDukGkcqOIxoU9d7zSOxU5LZQ1EB1KA981xmZpPIIfaowgdia8FSxaQrBonQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@parcel/watcher": "^2.5.1", + "@tailwindcss/node": "4.1.3", + "@tailwindcss/oxide": "4.1.3", + "enhanced-resolve": "^5.18.1", + "mri": "^1.2.0", + "picocolors": "^1.1.1", + "tailwindcss": "4.1.3" + }, + "bin": { + "tailwindcss": "dist/index.mjs" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.3.tgz", + "integrity": "sha512-H/6r6IPFJkCfBJZ2dKZiPJ7Ueb2wbL592+9bQEl2r73qbX6yGnmQVIfiUvDRB2YI0a3PWDrzUwkvQx1XW1bNkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "enhanced-resolve": "^5.18.1", + "jiti": "^2.4.2", + "lightningcss": "1.29.2", + "tailwindcss": "4.1.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.3.tgz", + "integrity": "sha512-t16lpHCU7LBxDe/8dCj9ntyNpXaSTAgxWm1u2XQP5NiIu4KGSyrDJJRlK9hJ4U9yJxx0UKCVI67MJWFNll5mOQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.3", + "@tailwindcss/oxide-darwin-arm64": "4.1.3", + "@tailwindcss/oxide-darwin-x64": "4.1.3", + "@tailwindcss/oxide-freebsd-x64": "4.1.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.3", + "@tailwindcss/oxide-linux-x64-musl": "4.1.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.3.tgz", + "integrity": "sha512-cxklKjtNLwFl3mDYw4XpEfBY+G8ssSg9ADL4Wm6//5woi3XGqlxFsnV5Zb6v07dxw1NvEX2uoqsxO/zWQsgR+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.3.tgz", + "integrity": "sha512-mqkf2tLR5VCrjBvuRDwzKNShRu99gCAVMkVsaEOFvv6cCjlEKXRecPu9DEnxp6STk5z+Vlbh1M5zY3nQCXMXhw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.3.tgz", + "integrity": "sha512-7sGraGaWzXvCLyxrc7d+CCpUN3fYnkkcso3rCzwUmo/LteAl2ZGCDlGvDD8Y/1D3ngxT8KgDj1DSwOnNewKhmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.3.tgz", + "integrity": "sha512-E2+PbcbzIReaAYZe997wb9rId246yDkCwAakllAWSGqe6VTg9hHle67hfH6ExjpV2LSK/siRzBUs5wVff3RW9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.3.tgz", + "integrity": "sha512-GvfbJ8wjSSjbLFFE3UYz4Eh8i4L6GiEYqCtA8j2Zd2oXriPuom/Ah/64pg/szWycQpzRnbDiJozoxFU2oJZyfg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.3.tgz", + "integrity": "sha512-35UkuCWQTeG9BHcBQXndDOrpsnt3Pj9NVIB4CgNiKmpG8GnCNXeMczkUpOoqcOhO6Cc/mM2W7kaQ/MTEENDDXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.3.tgz", + "integrity": "sha512-dm18aQiML5QCj9DQo7wMbt1Z2tl3Giht54uVR87a84X8qRtuXxUqnKQkRDK5B4bCOmcZ580lF9YcoMkbDYTXHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.3.tgz", + "integrity": "sha512-LMdTmGe/NPtGOaOfV2HuO7w07jI3cflPrVq5CXl+2O93DCewADK0uW1ORNAcfu2YxDUS035eY2W38TxrsqngxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.3.tgz", + "integrity": "sha512-aalNWwIi54bbFEizwl1/XpmdDrOaCjRFQRgtbv9slWjmNPuJJTIKPHf5/XXDARc9CneW9FkSTqTbyvNecYAEGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.3.tgz", + "integrity": "sha512-PEj7XR4OGTGoboTIAdXicKuWl4EQIjKHKuR+bFy9oYN7CFZo0eu74+70O4XuERX4yjqVZGAkCdglBODlgqcCXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.3.tgz", + "integrity": "sha512-T8gfxECWDBENotpw3HR9SmNiHC9AOJdxs+woasRZ8Q/J4VHN0OMs7F+4yVNZ9EVN26Wv6mZbK0jv7eHYuLJLwA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/easing-utils": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/easing-utils/-/easing-utils-1.0.0.tgz", + "integrity": "sha512-9oabISTqjTJSZyu85nJMfYtlV2tT/Uwo0M3uqODrBWCxme0eyLqEXRuG2/uYpyqzc7iSHjV/KW2mEKTqhdtESA==", + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", + "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.23.9", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz", + "integrity": "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.0", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-regex": "^1.2.1", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.0", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.3", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.3", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.18" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true, + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", + "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-proto": "^1.0.0", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", + "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz", + "integrity": "sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.29.2", + "lightningcss-darwin-x64": "1.29.2", + "lightningcss-freebsd-x64": "1.29.2", + "lightningcss-linux-arm-gnueabihf": "1.29.2", + "lightningcss-linux-arm64-gnu": "1.29.2", + "lightningcss-linux-arm64-musl": "1.29.2", + "lightningcss-linux-x64-gnu": "1.29.2", + "lightningcss-linux-x64-musl": "1.29.2", + "lightningcss-win32-arm64-msvc": "1.29.2", + "lightningcss-win32-x64-msvc": "1.29.2" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.2.tgz", + "integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.2.tgz", + "integrity": "sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.2.tgz", + "integrity": "sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.2.tgz", + "integrity": "sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.2.tgz", + "integrity": "sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.2.tgz", + "integrity": "sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.2.tgz", + "integrity": "sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.2.tgz", + "integrity": "sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.2.tgz", + "integrity": "sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.2.tgz", + "integrity": "sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/livereload": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/livereload/-/livereload-0.9.3.tgz", + "integrity": "sha512-q7Z71n3i4X0R9xthAryBdNGVGAO2R5X+/xXpmKeuPMrteg+W2U8VusTKV3YiJbXZwKsOlFlHe+go6uSNjfxrZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.0", + "livereload-js": "^3.3.1", + "opts": ">= 1.2.0", + "ws": "^7.4.3" + }, + "bin": { + "livereload": "bin/livereload.js" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/livereload-js": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/livereload-js/-/livereload-js-3.4.1.tgz", + "integrity": "sha512-5MP0uUeVCec89ZbNOT/i97Mc+q3SxXmiUGhRFOTmhrGPn//uWVQdCvcLJDy64MSBR5MidFdOR7B9viumoavy6g==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", + "dev": true, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-all": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", + "integrity": "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "chalk": "^2.4.1", + "cross-spawn": "^6.0.5", + "memorystream": "^0.3.1", + "minimatch": "^3.0.4", + "pidtree": "^0.3.0", + "read-pkg": "^3.0.0", + "shell-quote": "^1.6.1", + "string.prototype.padend": "^3.0.0" + }, + "bin": { + "npm-run-all": "bin/npm-run-all/index.js", + "run-p": "bin/run-p/index.js", + "run-s": "bin/run-s/index.js" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/opts": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/opts/-/opts-2.0.2.tgz", + "integrity": "sha512-k41FwbcLnlgnFh69f4qdUfvDQ+5vaSDnVPFI/y5XuhKRq97EnVVneO9F1ESVCdiVu4fCS2L8usX3mU331hB7pg==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pidtree": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.3.1.tgz", + "integrity": "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/prettier": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-jinja-template": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-jinja-template/-/prettier-plugin-jinja-template-2.0.0.tgz", + "integrity": "sha512-REZDAcZuOUvMDaPS47/GNRLKvbxh9DO9euXhWA7gJGqTLGzHPK2Z841F8I4bxsR7e2lqnHezkQ8GcWaKekKBVQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "prettier": "^3.0.0" + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.11.tgz", + "integrity": "sha512-YxaYSIvZPAqhrrEpRtonnrXdghZg1irNg4qrjboCXrpybLWVs55cW2N3juhspVJiO0JBvYJT8SYsJpc8OQSnsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-import-sort": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-style-order": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-import-sort": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-style-order": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, + "node_modules/read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shell-quote": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz", + "integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.21", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz", + "integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/string.prototype.padend": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.6.tgz", + "integrity": "sha512-XZpspuSB7vJWhvJc9DLSlrXl1mcA2BdoY5jjnS135ydXqLoqhs96JjDtCkjJEQHvfqZIp9hBuBMgI589peyx9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.3.tgz", + "integrity": "sha512-2Q+rw9vy1WFXu5cIxlvsabCwhU2qUwodGq03ODhLJ0jW4ek5BUtoCsnLB0qG+m8AHgEsSJcJGDSDe06FXlP74g==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..c0a69486 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "scripts": { + "watch": "npm-run-all --parallel watch:styles watch:livereload", + "watch:styles": "npx tailwindcss -i ./frontend/styles/main.css -o ./frontend/static/main.css --watch", + "watch:livereload": "npx livereload ./frontend/static", + "build:styles": "npx tailwindcss -i ./frontend/styles/main.css -o ./frontend/static/main.css", + "build:styles.min": "npx tailwindcss -i ./frontend/styles/main.css -o ./frontend/static/main.css --minify", + "format": "npx prettier ./frontend/templates ./frontend/styles --config frontend/prettier.config.cjs --write", + "format:check": "npx prettier ./frontend/templates ./frontend/styles --config frontend/prettier.config.cjs --check" + }, + "devDependencies": { + "@tailwindcss/cli": "^4.1.2", + "livereload": "^0.9.3", + "npm-run-all": "^4.1.5", + "prettier": "^3.5.3", + "prettier-plugin-jinja-template": "^2.0.0", + "prettier-plugin-tailwindcss": "^0.6.11" + } +} diff --git a/rustfmt.toml b/rustfmt.toml index a71193aa..94679a8b 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -3,3 +3,6 @@ chain_width = 80 fn_call_width = 80 struct_lit_width = 75 single_line_if_else_max_width = 80 +group_imports = "StdExternalCrate" +imports_granularity = "Module" +fn_params_layout = "Compressed" diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 2fa91abb..6bb51f1c 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -8,36 +8,33 @@ fi ssh $1 <<'EOS' # update sudo yum update -y +sudo timedatectl set-timezone America/New_York # create a user -if ! id wlsd &>/dev/null; then - sudo adduser wlsd +if ! id lsd &>/dev/null; then + sudo adduser lsd fi -# add ssh keys -cat > .ssh/authorized_keys </dev/null +sudo tee /etc/systemd/system/lsd.service </dev/null [Unit] -Description=WLSD +Description=LSD After=network.target [Service] Type=simple -User=wlsd -WorkingDirectory=/home/wlsd -ExecStart=/home/wlsd/wlsd /home/wlsd/config/prod.toml +User=lsd +WorkingDirectory=/home/lsd +ExecStart=/home/lsd/lsd Restart=always [Install] WantedBy=multi-user.target EOF sudo systemctl daemon-reload -sudo systemctl enable wlsd -sudo systemctl restart wlsd +sudo systemctl enable lsd +sudo systemctl restart lsd EOS diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 5d40d145..df3b9abc 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -5,12 +5,24 @@ if [ $# -ne 1 ]; then exit 1 fi -cargo build --profile prod --target aarch64-unknown-linux-gnu +rsync --rsync-path="sudo rsync" -Pavz target/aarch64-unknown-linux-gnu/release/lsd $1:/home/lsd/lsd.next +rsync --rsync-path="sudo rsync" -Pavz --delete frontend/static/ $1:/home/lsd/static/ -ls -l target/aarch64-unknown-linux-gnu/ -ls -l target/aarch64-unknown-linux-gnu/* -rsync --rsync-path="sudo rsync" -Pavzr --delete assets templates config target/aarch64-unknown-linux-gnu/prod/wlsd $1:/home/wlsd/ ssh $1 <<'EOS' -sudo setcap 'cap_net_bind_service=+ep' /home/wlsd/wlsd -sudo systemctl restart wlsd +set -euxo pipefail +sudo systemctl stop lsd +sudo sqlite3 /home/lsd/db.sqlite "PRAGMA wal_checkpoint(TRUNCATE);" + +TS=$(date +'%Y-%m-%d_%H-%M-%S') +sudo cp -a /home/lsd/lsd /home/lsd/backups/lsd.$TS +sudo cp -a /home/lsd/db.sqlite /home/lsd/backups/db.$TS.sqlite +sudo ln -sf /home/lsd/backups/lsd.$TS /home/lsd/backups/lsd.latest +sudo ln -sf /home/lsd/backups/db.$TS.sqlite /home/lsd/backups/db.latest.sqlite + +sudo mv /home/lsd/lsd.next /home/lsd/lsd +sudo setcap 'cap_net_bind_service=+ep' /home/lsd/lsd + +sudo systemctl start lsd +sleep 1 +sudo systemctl is-active --quiet lsd EOS diff --git a/scripts/rollback.sh b/scripts/rollback.sh new file mode 100755 index 00000000..a92f1a73 --- /dev/null +++ b/scripts/rollback.sh @@ -0,0 +1,24 @@ +#!/bin/bash +set -euxo pipefail +if [ $# -ne 1 ]; then + echo "Usage: scripts/rollback.sh @" + exit 1 +fi + +#ssh $1 <<'EOS' +ssh -i ~/.ssh/id_ed25519_lsd_root ec2-user@beta.lightandsound.design <<'EOS' +set -euxo pipefail +sudo systemctl stop lsd +sudo sqlite3 /home/lsd/db.sqlite "PRAGMA wal_checkpoint(TRUNCATE);" + +TS=$(date +'%Y-%m-%d_%H-%M-%S') +sudo cp /home/lsd/db.sqlite /home/lsd/backups/db.rollback.$TS.sqlite + +sudo cp -aL /home/lsd/backups/lsd.latest /home/lsd/lsd +sudo cp -aL /home/lsd/backups/db.latest.sqlite /home/lsd/db.sqlite + +sudo systemctl reset-failed lsd || true +sudo systemctl start lsd +sleep 1 +sudo systemctl is-active --quiet lsd +EOS diff --git a/src/app/auth.rs b/src/app/auth.rs new file mode 100644 index 00000000..2c51c0c9 --- /dev/null +++ b/src/app/auth.rs @@ -0,0 +1,184 @@ +//! A simple passwordless authentication flow using one-time links sent via email. +//! +//! TODO: Switch to one-time codes (123-456) instead of links: +//! * More robust against clients and intermediaries that auto-open URLs +//! * Easier to transfer across devices than a magic link +//! +//! We choose this scheme instead of one with usernames/passwords to reduce +//! friction and simplify onboarding. +//! +//! # High-level flow +//! +//! 1. **Email input**: User enters their email and submits a login form. +//! 2. **Token generated**: Server creates a short-lived link with a login token and emails it to the user. +//! - If the user is already registered, the link points to `/login?token=...`. +//! - If the user is not registered, the link points to `/register?token=...`. +//! 3. **Link clicked**: User clicks the link, passing the token back to the server. +//! - `/login`: The user gets a new session cookie and is redirected home. +//! - `/register`: The user is prompted to enter their first/last name. +//! Upon submission, the user gets a new session cookie and is redirected home. + +use lettre::message::Mailbox; +use lettre::message::header::ContentType; + +use crate::db::token::{LoginToken, SessionToken}; +use crate::prelude::*; + +/// Add all `auth` routes to the router. +#[rustfmt::skip] +pub fn add_routes(router: AppRouter) -> AppRouter { + router.public_routes(|r| { + r.route("/login", post(login_form).get(login_link)) + }) +} + +/// Add all `auth` middleware to the router. +pub fn add_middleware(router: AxumRouter, state: SharedAppState) -> AxumRouter { + /// Middleware layer to lookup add a `User` to the request if a session token is present. + pub async fn session_middleware( + State(state): State, mut cookies: CookieJar, mut request: Request, next: Next, + ) -> HtmlResult { + if let Some(token) = cookies.get("session") { + match User::lookup_by_session_token(&state.db, token.value()).await? { + Some(user) => { + request.extensions_mut().insert(user); + } + None => cookies = cookies.remove("session"), + } + } + let response = next.run(request).await; + Ok((cookies, response).into_response()) + } + router.layer(axum::middleware::from_fn_with_state(state, session_middleware)) +} +/// Enable extracting an `Option` in a handler. +impl axum::extract::OptionalFromRequestParts for User { + type Rejection = Infallible; + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result, Self::Rejection> { + Ok(parts.extensions.get::().cloned()) + } +} +/// Enable extracting a `User` in a handler, redirecting to /login if not logged in. +impl axum::extract::FromRequestParts for User { + type Rejection = Redirect; + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + match parts.extensions.get::().cloned() { + Some(user) => Ok(user), + None => Err(Redirect::to(&format!("/login?redirect={}", parts.uri.path()))), + } + } +} + +#[derive(Template, WebTemplate)] +#[template(path = "auth/login_email_sent.html")] +struct LoginEmailSentHtml { + user: Option, + email: String, +} +/// Process a login form and send either a login or registration link via email. +async fn login_form(State(state): State, Form(form): Form) -> HtmlResult { + let email = form.email.email.to_string(); + let Some(user) = User::lookup_by_email(&state.db, &email).await? else { + bail_unauthorized!(); + }; + + let email_id = Email::create_login(&state.db, &user).await?; + + // Delete any existing tokens and re-create + LoginToken::delete_by_user(&state.db, &user).await?; + let login_token = LoginToken::create(&state.db, &user).await?; + + let base_url = &state.config.app.url; + let login_url = match form.redirect { + Some(redirect) => format!("{base_url}/login?token={login_token}&redirect={redirect}"), + None => format!("{base_url}/login?token={login_token}"), + }; + let domain = &state.config.app.domain; + + #[derive(Template)] + #[template(path = "emails/login.html")] + struct LoginEmailHtml { + email_id: i64, + login_url: String, + }; + + let msg = state + .mailer + .builder() + .header(ContentType::TEXT_HTML) + .to(form.email) + .subject(format!("Login to {domain}")) + .body(LoginEmailHtml { email_id, login_url }.render()?)?; + + match state.mailer.send(&msg).await { + Ok(_) => { + Email::mark_sent(&state.db, email_id).await?; + Ok(LoginEmailSentHtml { user: None, email }.into_response()) + } + Err(e) => { + Email::mark_error(&state.db, email_id, e.message()).await?; + Err(e.into()) + } + } +} +#[derive(serde::Deserialize)] +struct LoginForm { + email: Mailbox, + redirect: Option, +} + +/// Show the login page or handle a login link +#[derive(serde::Deserialize)] +struct LoginQuery { + redirect: Option, + token: Option, +} +impl LoginQuery { + fn redirect(&self) -> Redirect { + Redirect::to(match &self.redirect { + Some(url) => url, + None => "/", + }) + } +} +async fn login_link( + user: Option, State(state): State, Query(query): Query, +) -> HtmlResult { + // If user is already logged in for some reason, just follow the redirect + if user.is_some() { + return Ok(query.redirect().into_response()); + } + + // If there's no token, just show the login page. + let Some(token) = &query.token else { + #[derive(Template, WebTemplate)] + #[template(path = "auth/login.html")] + struct Html { + user: Option, + #[allow(unused)] + redirect: Option, + }; + return Ok(Html { user, redirect: query.redirect }.into_response()); + }; + + // Otherwise we're handling a login link. Valdiate the login token and create a new session. + let Some(user) = User::lookup_by_login_token(&state.db, token).await? else { + bail_not_found!(); + }; + LoginToken::delete_by_token(&state.db, token).await?; + let token = SessionToken::create(&state.db, &user).await?; + let cookie = session_cookie(&state.config, token); + + let headers = [(header::SET_COOKIE, cookie)]; + Ok((headers, query.redirect()).into_response()) +} + +fn session_cookie(config: &Config, token: String) -> String { + Cookie::build(("session", token)) + .secure(config.acme.is_some()) + .http_only(true) + .same_site(cookie::SameSite::Strict) + .domain(&config.app.domain) + .max_age(cookie::time::Duration::days(config.app.session_expiry_days as i64)) + .to_string() +} diff --git a/src/app/bulletin.rs b/src/app/bulletin.rs new file mode 100644 index 00000000..c09c6fe8 --- /dev/null +++ b/src/app/bulletin.rs @@ -0,0 +1,381 @@ +use axum::Json; +use axum::extract::Query; +use axum::http::header; +use axum::response::IntoResponse; +use axum::routing::{get, post}; +use serde::{Deserialize, Serialize}; + +use crate::prelude::*; +use crate::utils::error::not_found; + +#[derive(Debug, Serialize, Deserialize)] +struct Flyer { + id: i64, + user_id: i64, + flyer_name: Option, + x: i64, + y: i64, + rotation: i64, + link_url: Option, + image_version: i64, +} + +async fn create_flyer( + user: User, State(state): State, mut multipart: axum::extract::Multipart, +) -> HtmlResult { + let mut image_data: Option> = None; + let mut link_url: Option = None; + let mut flyer_name: Option = None; + let mut x: Option = None; + let mut y: Option = None; + + while let Some(field) = multipart.next_field().await? { + match field.name().unwrap_or("") { + "image_file" => { + let data = field.bytes().await?; + if !data.is_empty() { + let img = crate::utils::image::decode(&data).await?; + image_data = Some(crate::utils::image::encode_jpeg(&img, Some(1200)).await); + } + } + "link_url" => { + let v = field.text().await?; + link_url = if v.is_empty() { None } else { Some(v) }; + } + "flyer_name" => { + flyer_name = Some(field.text().await?); + } + "x" => { + x = field.text().await?.parse().ok(); + } + "y" => { + y = field.text().await?.parse().ok(); + } + _ => {} + } + } + + let image_data = image_data.ok_or_else(|| crate::utils::error::invalid())?; + let flyer_name = flyer_name.unwrap_or_default(); + let x = x.unwrap_or(0); + let y = y.unwrap_or(0); + + sqlx::query!( + r#"INSERT INTO flyers (user_id, x, y, image_data, link_url, flyer_name) + VALUES (?, ?, ?, ?, ?, ?)"#, + user.id, + x, + y, + image_data, + link_url, + flyer_name, + ) + .execute(&state.db) + .await?; + + Ok(Redirect::to(&format!("/bulletin#x={x}&y={y}")).into_response()) +} + +#[derive(Debug, Serialize, Deserialize)] +struct FlyerUpdate { + link_url: Option, + flyer_name: String, +} + +async fn update_flyer( + user: User, State(state): State, Path(id): Path, + mut multipart: axum::extract::Multipart, +) -> HtmlResult { + let mut image_data: Option> = None; + let mut link_url: Option = None; + let mut flyer_name: Option = None; + + while let Some(field) = multipart.next_field().await? { + match field.name().unwrap_or("") { + "image_file" => { + let data = field.bytes().await?; + if !data.is_empty() { + let img = crate::utils::image::decode(&data).await?; + image_data = Some(crate::utils::image::encode_jpeg(&img, Some(1200)).await); + } + } + "link_url" => { + let v = field.text().await?; + link_url = if v.is_empty() { None } else { Some(v) }; + } + "flyer_name" => { + flyer_name = Some(field.text().await?); + } + _ => {} + } + } + + let flyer_name = flyer_name.unwrap_or_default(); + + if user.has_role(User::ADMIN) { + if let Some(data) = image_data { + sqlx::query!( + "UPDATE flyers SET image_data = ?, image_version = image_version + 1, link_url = ?, flyer_name = ? WHERE id = ?", + data, + link_url, + flyer_name, + id, + ) + .execute(&state.db) + .await?; + } else { + sqlx::query!( + "UPDATE flyers SET link_url = ?, flyer_name = ? WHERE id = ?", + link_url, + flyer_name, + id, + ) + .execute(&state.db) + .await?; + } + } else if let Some(data) = image_data { + sqlx::query!( + "UPDATE flyers SET image_data = ?, image_version = image_version + 1, link_url = ?, flyer_name = ? WHERE id = ? AND user_id = ?", + data, + link_url, + flyer_name, + id, + user.id, + ) + .execute(&state.db) + .await?; + } else { + sqlx::query!( + "UPDATE flyers SET link_url = ?, flyer_name = ? WHERE id = ? AND user_id = ?", + link_url, + flyer_name, + id, + user.id, + ) + .execute(&state.db) + .await?; + } + + let pos = sqlx::query!("SELECT x, y FROM flyers WHERE id = ?", id) + .fetch_one(&state.db) + .await?; + Ok(Redirect::to(&format!("/bulletin#x={}&y={}", pos.x, pos.y)).into_response()) +} + +async fn admin_update_flyer( + user: User, State(state): State, Path(id): Path, + mut multipart: axum::extract::Multipart, +) -> HtmlResult { + let _ = user; + let mut image_data: Option> = None; + let mut link_url: Option = None; + let mut flyer_name: Option = None; + + while let Some(field) = multipart.next_field().await? { + match field.name().unwrap_or("") { + "image_file" => { + let data = field.bytes().await?; + if !data.is_empty() { + let img = crate::utils::image::decode(&data).await?; + image_data = Some(crate::utils::image::encode_jpeg(&img, Some(1200)).await); + } + } + "link_url" => { + let v = field.text().await?; + link_url = if v.is_empty() { None } else { Some(v) }; + } + "flyer_name" => { + flyer_name = Some(field.text().await?); + } + _ => {} + } + } + + let flyer_name = flyer_name.unwrap_or_default(); + + if let Some(data) = image_data { + sqlx::query!( + "UPDATE flyers SET image_data = ?, image_version = image_version + 1, link_url = ?, flyer_name = ? WHERE id = ?", + data, + link_url, + flyer_name, + id, + ) + .execute(&state.db) + .await?; + } else { + sqlx::query!( + "UPDATE flyers SET link_url = ?, flyer_name = ? WHERE id = ?", + link_url, + flyer_name, + id, + ) + .execute(&state.db) + .await?; + } + + Ok(Redirect::to("/bulletin/admin").into_response()) +} + +#[derive(Debug, Serialize, Deserialize)] +struct FlyerMoveUpdate { + x: i64, + y: i64, + rotation: i64, +} + +async fn move_flyer( + user: User, State(state): State, Path(id): Path, Json(update): Json, +) -> JsonResult<()> { + if user.has_role(User::ADMIN) { + sqlx::query!( + r#"UPDATE flyers + SET x = ?, y = ?, rotation = ? + WHERE id = ?"#, + update.x, + update.y, + update.rotation, + id, + ) + .execute(&state.db) + .await?; + } else { + sqlx::query!( + r#"UPDATE flyers + SET x = ?, y = ?, rotation = ? + WHERE id = ? AND user_id = ?"#, + update.x, + update.y, + update.rotation, + id, + user.id + ) + .execute(&state.db) + .await?; + } + + Ok(Json(())) +} + +async fn delete_flyer( + user: User, State(state): State, Path(id): Path, +) -> JsonResult<()> { + if user.has_role(User::ADMIN) { + sqlx::query!("DELETE FROM flyers WHERE id = ?", id).execute(&state.db).await?; + } else { + sqlx::query!("DELETE FROM flyers WHERE id = ? AND user_id = ?", id, user.id) + .execute(&state.db) + .await?; + } + + Ok(Json(())) +} + +async fn flyer_details(State(state): State, Path(id): Path) -> JsonResult { + let flyer = sqlx::query_as!(FlyerUpdate, "SELECT flyer_name, link_url FROM flyers WHERE id = ?", id) + .fetch_one(&state.db) + .await?; + + Ok(Json(flyer)) +} + +async fn serve_flyer_image(State(state): State, Path(id): Path) -> HtmlResult { + let data = sqlx::query_scalar!("SELECT image_data FROM flyers WHERE id = ?", id) + .fetch_optional(&state.db) + .await? + .ok_or_else(not_found)?; + + Ok(( + [ + (header::CONTENT_TYPE, "image/jpeg"), + (header::CACHE_CONTROL, "public, max-age=31536000, immutable"), + ], + data, + ) + .into_response()) +} + +async fn bulletin_page(user: Option, State(state): State) -> HtmlResult { + let flyers = sqlx::query_as!( + Flyer, + "SELECT id, user_id, flyer_name, x, y, rotation, link_url, image_version FROM flyers" + ) + .fetch_all(&state.db) + .await?; + + let (editable_flyers, read_only_flyers) = if let Some(ref u) = user { + let is_admin = u.has_role(User::ADMIN); + if is_admin { + (flyers, Vec::new()) + } else { + flyers.into_iter().partition(|f| f.user_id == u.id) + } + } else { + (Vec::new(), flyers) + }; + + #[derive(Template, WebTemplate)] + #[template(path = "bulletin/index.html")] + struct Html { + read_only_flyers: Vec, + editable_flyers: Vec, + user: Option, + } + + Ok(Html { read_only_flyers, editable_flyers, user }.into_response()) +} + +#[derive(Deserialize)] +struct AdminListParams { + page: Option, +} + +const PAGE_SIZE: i64 = 25; + +async fn admin_flyer_list( + user: User, State(state): State, Query(params): Query, +) -> HtmlResult { + let page = params.page.unwrap_or(1).max(1); + let offset = (page - 1) * PAGE_SIZE; + + let total = sqlx::query_scalar!("SELECT COUNT(*) FROM flyers").fetch_one(&state.db).await?; + + let flyers = sqlx::query_as!( + Flyer, + "SELECT id, user_id, flyer_name, x, y, rotation, link_url, image_version + FROM flyers ORDER BY id DESC LIMIT ? OFFSET ?", + PAGE_SIZE, + offset + ) + .fetch_all(&state.db) + .await?; + + let total_pages = (total + PAGE_SIZE - 1) / PAGE_SIZE; + + #[derive(Template, WebTemplate)] + #[template(path = "bulletin/admin.html")] + struct Html { + user: Option, + flyers: Vec, + page: i64, + total_pages: i64, + } + + Ok(Html { user: Some(user), flyers, page, total_pages }.into_response()) +} + +pub fn add_routes(router: AppRouter) -> AppRouter { + router + .public_routes(|r| { + r.route("/bulletin", get(bulletin_page)) + .route("/bulletin/flyer/new", post(create_flyer)) + .route("/bulletin/flyer/{id}", delete(delete_flyer).get(flyer_details)) + .route("/bulletin/flyer/{id}/edit", post(update_flyer)) + .route("/bulletin/flyer/{id}/move", post(move_flyer)) + .route("/bulletin/flyer/{id}/image", get(serve_flyer_image)) + }) + .restricted_routes(User::ADMIN, |r| { + r.route("/bulletin/admin", get(admin_flyer_list)) + .route("/bulletin/admin/flyer/{id}/edit", post(admin_update_flyer)) + }) +} diff --git a/src/app/contact.rs b/src/app/contact.rs new file mode 100644 index 00000000..388e0b56 --- /dev/null +++ b/src/app/contact.rs @@ -0,0 +1,71 @@ +use std::net::SocketAddr; + +use axum::extract::ConnectInfo; +use lettre::message::Mailbox; + +use crate::prelude::*; + +pub fn add_routes(router: AppRouter) -> AppRouter { + router.public_routes(|r| r.route("/contact", get(contact_page).post(contact_form))) +} + +async fn contact_page(user: Option, State(state): State) -> HtmlResult { + #[derive(Template, WebTemplate)] + #[template(path = "contact/send.html")] + struct Html { + user: Option, + turnstile_site_key: String, + }; + Ok(Html { + user, + turnstile_site_key: state.config.cloudflare.turnstile_site_key.clone(), + } + .into_response()) +} + +#[derive(serde::Deserialize, Debug)] +struct ContactForm { + name: String, + email: String, + subject: String, + message: String, + #[serde(rename = "cf-turnstile-response")] + turnstile_token: String, +} +async fn contact_form( + user: Option, State(state): State, ConnectInfo(client): ConnectInfo, + Form(form): Form, +) -> HtmlResult { + if !state.cloudflare.validate_turnstile(client.ip(), &form.turnstile_token).await? { + bail_invalid!(); + } + + let name = Some(form.name).filter(|n| !n.is_empty()); + let email = Some(form.email).filter(|e| !e.is_empty()); + + let to = state.config.email.contact_to.clone(); + let from = state.config.email.from.clone(); + let subject = match &name { + Some(name) => format!("[{name}]: {}", form.subject), + None => format!("[Anonymous]: {}", form.subject), + }; + let reply_to = match email { + Some(e) => Some(Mailbox::new(name, e.parse().map_err(|_| invalid())?)), + None => None, + }; + + let mut message = state.mailer.builder().to(to.unwrap_or(from)).subject(subject); + if let Some(reply_to) = reply_to { + message = message.reply_to(reply_to); + } + let message = message.body(form.message)?; + + state.mailer.send(&message).await?; + + #[derive(Template, WebTemplate)] + #[template(path = "contact/message_sent.html")] + struct Html { + user: Option, + }; + Ok(Html { user }.into_response()) +} diff --git a/src/app/emails.rs b/src/app/emails.rs new file mode 100644 index 00000000..a7289a3f --- /dev/null +++ b/src/app/emails.rs @@ -0,0 +1,95 @@ +use crate::db::list::List; +use crate::prelude::*; + +/// Add all `email` routes to the router. +#[rustfmt::skip] +pub fn add_routes(router: AppRouter) -> AppRouter { + router.public_routes(|r| { + r.route("/emails/{id}/footer.gif", get(email_opened)) + .route("/emails/{id}/unsubscribe", get(email_unsubscribe_view).post(email_unsubscribe_form)) + }) +} + +async fn email_opened(Path(email_id): Path, State(state): State) -> HtmlResult { + // Mark opened IF it exists. Not found is not an error, we still return the pixel. + Email::mark_opened(&state.db, email_id).await?; + + let pixel = Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "image/gif") + .body(PIXEL.into()) + .unwrap(); + Ok(pixel) +} + +async fn email_unsubscribe_view( + user: Option, Path(email_id): Path, State(state): State, +) -> HtmlResult { + // TODO: Better error handling rather than silently eating + if let Some(email) = Email::lookup(&state.db, email_id).await? { + let list_id = email.list_id.ok_or_else(invalid)?; + let list = List::lookup_by_id(&state.db, list_id).await?.ok_or_else(invalid)?; + + #[derive(Template, WebTemplate)] + #[template(path = "emails/unsubscribe.html")] + struct UnsubscribeHtml { + user: Option, + list: List, + email_id: i64, + } + Ok(UnsubscribeHtml { user, list, email_id }.into_response()) + } else { + // XXX: Use a unique unsubscribe id instead of sequential email ids. + // TODO: Record unsubscription, rather than assuming no match means you already unsubscribed. + #[derive(Template, WebTemplate)] + #[template(path = "emails/already_unsubscribed.html")] + struct AlreadyUnsubscribedHtml { + user: Option, + } + Ok(AlreadyUnsubscribedHtml { user }.into_response()) + } +} + +async fn email_unsubscribe_form( + Path(email_id): Path, State(state): State, +) -> HtmlResult { + // TODO: Better error handling rather than silently eating + if let Some(email) = Email::lookup(&state.db, email_id).await? + && let Some(list_id) = email.list_id + { + List::remove_member(&state.db, list_id, email.user_id).await?; + } + Ok("You have been unsubscribed.".into_response()) +} + +/// A 1x1 transparent GIF. +#[rustfmt::skip] +const PIXEL: &[u8] = &[ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // Header: "GIF89a" + 0x01, 0x00, // Logical Screen Width: 1 + 0x01, 0x00, // Logical Screen Height: 1 + 0x80, // GCT flag = 1, Color Resolution = 0, Sort = 0, GCT Size = 2^(0+1)=2 colors + 0x00, // Background Color Index = 0 + 0x00, // Pixel Aspect Ratio = 0 (no aspect ratio given) + // Global Color Table (2 entries, each 3 bytes: RGB) + 0x00, 0x00, 0x00, // Index #0: black (will be set as transparent) + 0x00, 0x00, 0x00, // Index #1: black + // Graphic Control Extension + 0x21, 0xF9, 0x04, // Extension Introducer (0x21), GCE Label (0xF9), Block Size (4) + 0x01, // Packed Fields: bit 0 = 1 => Transparent Color Flag + 0x00, 0x00, // Delay Time = 0 + 0x00, // Transparent Color Index = 0 + 0x00, // Block Terminator + // Image Descriptor + 0x2C, // Image separator: ',' + 0x00, 0x00, 0x00, 0x00, // Image Position: (0,0) + 0x01, 0x00, // Image Width: 1 + 0x01, 0x00, // Image Height: 1 + 0x00, // No Local Color Table, no interlace, etc. + // Image Data + 0x02, // LZW Minimum Code Size + 0x02, // Block Size (number of bytes of LZW data in this sub-block) + 0x4C, 0x01, // LZW-compressed data + 0x00, // Block Terminator (end of image data) + 0x3B, // Trailer: ';' +]; diff --git a/src/app/events.rs b/src/app/events.rs new file mode 100644 index 00000000..8a167f0f --- /dev/null +++ b/src/app/events.rs @@ -0,0 +1,2051 @@ +use image::DynamicImage; + +use crate::db::event::{Event, EventLimits, EventWithStats, UpdateEvent}; +use crate::db::event_flyer::*; +use crate::db::rsvp_session::*; +use crate::db::spot::*; +use crate::prelude::*; + +/// Add all `events` routes to the router. +#[rustfmt::skip] +pub fn add_routes(router: AppRouter) -> AppRouter { + router + .public_routes(|r| { + r.route("/e/{slug}", get(read::view_page)) + .route("/e/{slug}/flyer", get(read::flyer)) + .route("/e/{slug}/rsvp", get(rsvp::rsvp_form)) + .route("/e/{slug}/rsvp/guestlist", get(rsvp::guestlist_page).post(rsvp::guestlist_form)) + .route("/e/{slug}/rsvp/selection", get(rsvp::selection_page).post(rsvp::selection_form)) + .route("/e/{slug}/rsvp/attendees", get(rsvp::attendees_page).post(rsvp::attendees_form)) + .route("/e/{slug}/rsvp/contribution", get(rsvp::contribution_page).post(rsvp::contribution_form)) + .route("/e/{slug}/rsvp/manage", get(rsvp::manage_page)) // REMOVEME .post(rsvp::temp_delete)) + .route("/e/{slug}/rsvp/edit", get(rsvp::edit_guests_page).post(rsvp::edit_guests_form)) + }) + .restricted_routes(User::ADMIN, |r| { + r.route("/events", get(read::list_page)) + .route("/events/sessions", get(read::sessions_page)) + .route("/events/sessions/{id}", delete(read::delete_session)) + .route("/events/new", get(edit::new_page)) + .route("/events/{slug}/edit", get(edit::edit_page).post(edit::edit_form)) + .route("/events/{slug}/delete", post(edit::delete_form)) + .route("/events/{slug}/duplicate", post(edit::duplicate_form)) + .route("/events/{slug}/attendees", get(edit::attendees_page)) + .route("/events/{slug}/attendees/add", get(edit::add_attendee_page).post(edit::add_attendee_form)) + .route("/events/{slug}/attendees/{user_id}", delete(edit::delete_attendee)) + .route("/events/{slug}/attendees/{user_id}/checkin", post(edit::set_checkin).delete(edit::clear_checkin)) + .route("/events/{id}/invite/edit", get(edit::edit_invite_page).post(edit::edit_invite_form)) + .route("/events/{id}/invite/preview", get(edit::preview_invite_page)) + .route("/events/{id}/invite/send", get(edit::send_invite_page).post(edit::send_invite_form)) + .route("/events/{id}/confirmation/edit", get(edit::edit_confirmation_page).post(edit::edit_confirmation_form)) + .route("/events/{id}/confirmation/preview", get(edit::preview_confirmation_page)) + .route("/events/{id}/dayof/edit", get(edit::edit_dayof_page).post(edit::edit_dayof_form)) + .route("/events/{id}/dayof/preview", get(edit::preview_dayof_page)) + .route("/events/{id}/dayof/send", get(edit::send_dayof_page).post(edit::send_dayof_form)) + .route("/events/{id}/description/edit", get(edit::edit_description_page).post(edit::edit_description_form)) + }) +} + +// View and list events. +mod read { + use super::*; + use crate::db::rsvp_session; + + /// View an event. + pub async fn view_page( + session: Option, user: Option, State(state): State, + Path(slug): Path, + ) -> HtmlResult { + #[derive(Template, WebTemplate)] + #[template(path = "events/view.html")] + struct Html { + session: Option, + pub user: Option, + event: Event, + flyer: Option, + } + let event = Event::lookup_by_slug(&state.db, &slug).await?.ok_or_else(not_found)?; + let flyer = EventFlyer::lookup(&state.db, event.id).await?; + Ok(Html { session, user, event, flyer }.into_response()) + } + + // List all events. + #[derive(Template, WebTemplate)] + #[template(path = "events/list.html")] + struct ListHtml { + user: Option, + events: Vec, + } + + pub async fn list_page(user: Option, State(state): State) -> HtmlResult { + Ok(ListHtml { user, events: Event::list(&state.db).await? }.into_response()) + } + + /// Serve an event flyer. + pub async fn flyer( + State(state): State, Path(slug): Path, + Query(params): Query>, + ) -> HtmlResult { + let event = Event::lookup_by_slug(&state.db, &slug).await?.ok_or_else(not_found)?; + + let size = match params.get("size").map(|s| s.as_str()) { + Some("sm") => EventFlyerSize::Small, + Some("md") => EventFlyerSize::Medium, + Some("lg") => EventFlyerSize::Large, + Some(_) => bail_invalid!(), + None => EventFlyerSize::Full, + }; + + let bytes = EventFlyer::serve(&state.db, event.id, size).await?.ok_or_else(not_found)?; + + Ok(( + [ + (header::CONTENT_TYPE, EventFlyer::CONTENT_TYPE), + (header::CACHE_CONTROL, "public, max-age=31536000, immutable"), + (HeaderName::from_static("priority"), "u=1"), // urgency below main.css (u=0) and above default (u=3) + ], + bytes, + ) + .into_response()) + } + + /// Debug view for RSVP sessions. + #[derive(Template, WebTemplate)] + #[template(path = "events/sessions.html")] + struct SessionsHtml { + user: Option, + sessions: Vec, + } + pub async fn sessions_page(user: User, State(state): State) -> HtmlResult { + let sessions = RsvpSession::list_debug(&state.db).await?; + Ok(SessionsHtml { user: Some(user), sessions }.into_response()) + } + + pub async fn delete_session(State(state): State, Path(id): Path) -> JsonResult<()> { + let session = RsvpSession::lookup_by_id(&state.db, id).await?.ok_or_else(not_found)?; + if session.status == RsvpSession::PAYMENT_PENDING || session.status == RsvpSession::PAYMENT_CONFIRMED + { + bail_invalid!(); + } + session.delete(&state.db).await?; + Ok(Json(())) + } +} + +// Create and edit events. +mod edit { + use axum::body::Body; + + use super::*; + use crate::db::list::{List, ListWithCount}; + use crate::db::manual_rsvp::ManualRsvp; + use crate::db::rsvp::{AdminAttendeesRsvp, Rsvp}; + use crate::db::user::CreateUser; + use crate::utils::editor::{Editor, EditorContent}; + + #[derive(Template, WebTemplate)] + #[template(path = "events/edit.html")] + struct EditHtml { + user: Option, + event: Event, + spots: Vec, + rsvp_counts: std::collections::HashMap, + has_flyer: bool, + lists: Vec, + } + + /// Display the form to create a new event. + pub async fn new_page(user: User, State(state): State) -> HtmlResult { + let lists = List::list_with_counts(&state.db).await?; + Ok(EditHtml { + user: Some(user), + event: Event { + id: 0, + title: "".into(), + slug: "".into(), + start: Utc::now().naive_utc(), + end: None, + capacity: 0, + unlisted: false, + closed: false, + guest_list_id: None, + spots_per_person: None, + + description_html: None, + description_updated_at: None, + + invite_subject: None, + invite_html: None, + invite_updated_at: None, + invite_sent_at: None, + + confirmation_subject: None, + confirmation_html: None, + confirmation_updated_at: None, + + dayof_subject: None, + dayof_html: None, + dayof_updated_at: None, + dayof_sent_at: None, + + created_at: Utc::now().naive_utc(), + updated_at: Utc::now().naive_utc(), + }, + spots: vec![], + rsvp_counts: Default::default(), + has_flyer: false, + lists, + } + .into_response()) + } + + /// Display the form to edit an event. + pub async fn edit_page( + user: User, State(state): State, Path(slug): Path, + ) -> HtmlResult { + let event = Event::lookup_by_slug(&state.db, &slug).await?.ok_or_else(not_found)?; + let spots = Spot::list_for_event(&state.db, event.id).await?; + let rsvp_counts = Spot::rsvp_counts_for_event(&state.db, event.id).await?; + let has_flyer = EventFlyer::exists_for_event(&state.db, event.id).await?; + let lists = List::list_with_counts(&state.db).await?; + Ok(EditHtml { user: Some(user), event, spots, rsvp_counts, has_flyer, lists }.into_response()) + } + + // Handle edit submission. + #[derive(Debug, serde::Deserialize)] + pub struct EditForm { + id: i64, + #[serde(flatten)] + event: UpdateEvent, + spots: Vec, + } + pub async fn edit_form( + State(state): State, mut multipart: axum::extract::Multipart, + ) -> JsonResult<()> { + let mut form: Option = None; + let mut flyer: Option = None; + + while let Some(field) = multipart.next_field().await? { + match field.name().unwrap_or("") { + "data" => { + let text = field.text().await?; + form = Some(serde_json::from_str(&text).map_err(|_| invalid())?); + } + "flyer" => { + let data = field.bytes().await?; + let img = crate::utils::image::decode(&data).await?; + flyer = Some(img); + } + _ => {} + } + } + + let form = form.ok_or_else(invalid)?; + + // Validate slug: must be non-empty and only contain alphanumeric characters and dashes + if form.event.slug.is_empty() + || !form.event.slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') + { + bail!("Slug can only contain letters, numbers, and dashes."); + } + + match form.id { + 0 => { + tracing::info!("create: {:?}", &form.event); + let event_id = Event::create(&state.db, &form.event, &flyer).await?; + + let mut spot_ids = vec![]; + for spot in form.spots { + let id = Spot::create(&state.db, &spot).await?; + spot_ids.push(id); + } + + Spot::add_to_event(&state.db, event_id, spot_ids).await?; + } + id => { + tracing::info!("edit: {:?}", &form.event); + Event::update(&state.db, id, &form.event, &flyer).await?; + + let rsvp_counts = Spot::rsvp_counts_for_event(&state.db, id).await?; + let mut to_add = vec![]; + let mut to_delete = Spot::list_ids_for_event(&state.db, id).await?; + + for spot in form.spots { + match spot.id { + Some(id) => { + Spot::update(&state.db, id, &spot).await?; + to_delete.retain(|&id_| id_ != id); + } + None => { + let id = Spot::create(&state.db, &spot).await?; + to_add.push(id); + } + } + } + + // Only delete spots with no confirmed RSVPs (cart items don't block deletion) + to_delete + .retain(|&spot_id| rsvp_counts.get(&spot_id).map(|c| c.rsvp_count).unwrap_or(0) == 0); + + Spot::add_to_event(&state.db, id, to_add).await?; + Spot::remove_from_event(&state.db, id, to_delete).await?; + } + } + + Ok(Json(())) + } + + // Edit invite page. + pub async fn edit_invite_page( + user: User, State(state): State, Path(id): Path, + ) -> HtmlResult { + let Some(event) = Event::lookup_by_id(&state.db, id).await? else { + bail_not_found!() + }; + + #[derive(Template, WebTemplate)] + #[template(path = "events/edit_invite.html")] + struct EditInviteHtml { + user: Option, + event: Event, + editor: Editor, + } + Ok(EditInviteHtml { + user: Some(user), + event: event.clone(), + editor: Editor { + url: "/events/{id}/invite/edit", + snapshot_prefix: "event/invite", + entity_id: Some(event.id), + content: match (event.invite_html, event.invite_updated_at) { + (Some(html), Some(updated_at)) => Some(EditorContent { html, updated_at }), + _ => None, + }, + }, + } + .into_response()) + } + + // Edit invite form. + #[derive(serde::Deserialize)] + pub struct EditInviteForm { + id: i64, + subject: String, + content: String, + } + #[derive(serde::Serialize)] + pub struct EditInviteResponse { + id: Option, + updated_at: Option, + error: Option, + } + pub async fn edit_invite_form( + State(state): State, Form(form): Form, + ) -> JsonResult { + let Some(event) = Event::lookup_by_id(&state.db, form.id).await? else { + bail_not_found!(); + }; + + let updated_at = Event::update_invite(&state.db, event.id, form.subject, form.content).await?; + + Ok(Json(EditInviteResponse { + id: Some(event.id), + updated_at: Some(updated_at.and_utc().timestamp_millis()), + error: None, + })) + } + + #[derive(Template, WebTemplate)] + #[template(path = "emails/event_invite.html")] + struct InviteEmailHtml { + email_id: i64, + email: String, + event: Event, + flyer: Option, + } + // Preview invite page. + pub async fn preview_invite_page( + user: User, State(state): State, Path(id): Path, + ) -> HtmlResult { + let Some(event) = Event::lookup_by_id(&state.db, id).await? else { + bail_not_found!() + }; + let flyer = EventFlyer::lookup(&state.db, event.id).await?; + + Ok(InviteEmailHtml { email_id: 0, email: user.email, event, flyer }.into_response()) + } + + /// Display the form to send a post. + pub async fn send_invite_page( + user: User, State(state): State, Path(id): Path, + ) -> HtmlResult { + let Some(event) = Event::lookup_by_id(&state.db, id).await? else { + bail_not_found!() + }; + let Some(guest_list_id) = event.guest_list_id else { + bail_invalid!() + }; + + #[derive(sqlx::FromRow)] + struct ListCounts { + name: String, + count: i64, + sent: i64, + } + let list = sqlx::query_as!( + ListCounts, + r#" + SELECT + l.name AS name, + COUNT(lm.user_id) AS count, + SUM( + CASE WHEN EXISTS ( + SELECT 1 + FROM emails e + WHERE kind = ? + AND e.user_id = u.id + AND e.event_id = ? + AND e.sent_at IS NOT NULL + ) + THEN 1 ELSE 0 END + ) AS sent + FROM lists l + LEFT JOIN list_members lm ON lm.list_id = l.id + LEFT JOIN users u ON u.id = lm.user_id + WHERE l.id = ? + GROUP BY l.id; + "#, + Email::EVENT_INVITE, + event.id, + guest_list_id, + ) + .fetch_one(&state.db) + .await?; + + #[derive(Template, WebTemplate)] + #[template(path = "events/send_invites.html")] + struct SendHtml { + user: Option, + list: ListCounts, + event: Event, + ratelimit: usize, + } + let ratelimit = state.config.email.ratelimit; + Ok(SendHtml { user: Some(user), event, list, ratelimit }.into_response()) + } + + pub async fn send_invite_form(State(state): State, Path(id): Path) -> HtmlResult { + let Some(event) = Event::lookup_by_id(&state.db, id).await? else { + bail_not_found!(); + }; + let Some(guest_list_id) = event.guest_list_id else { + bail_invalid!() + }; + + let emails = Email::create_send_invites(&state.db, event.id, guest_list_id).await?; + let flyer = EventFlyer::lookup(&state.db, event.id).await?; + + let mut email_template = + InviteEmailHtml { email_id: 0, email: "".into(), event: event.clone(), flyer }; + let mut messages = vec![]; + let mut email_ids = vec![]; + for Email { id, address, sent_at, .. } in emails { + if sent_at.is_some() { + continue; + } + + email_template.email_id = id; + email_template.email = address.clone(); + + let from = &state.config.email.from; + let reply_to = config().email.contact_to.as_ref().unwrap_or(from); + let message = state + .mailer + .builder() + .to(address.parse().unwrap()) + .reply_to(reply_to.clone()) + .subject(event.invite_subject.as_deref().expect("missing invite_subject")) + .header(lettre::message::header::ContentType::TEXT_HTML) + .body(email_template.render()?) + .unwrap(); + + messages.push(message); + email_ids.push(id); + } + + event.mark_sent_invites(&state.db).await?; + + let email_ids = futures::stream::iter(email_ids); + let results = state.mailer.send_batch(Arc::clone(&state), messages).await; + + let body = Body::from_stream(async_stream::stream! { + let mut stream = Box::pin(results.zip(email_ids)); + while let Some((progress, email_id)) = stream.next().await { + let json = match progress { + Ok(p) => { + Email::mark_sent(&state.db, email_id).await?; + json!({"sent": p.sent, "remaining": p.remaining}) + } + Err(e) => { + let e = e.message(); + Email::mark_error(&state.db, email_id, e).await?; + json!({"error": e}) + } + }.to_string(); + yield Ok::<_, AnyError>(format!("{json}\n")); + } + }); + + Ok(body.into_response()) + } + + // Edit confirmation page. + pub async fn edit_confirmation_page( + user: User, State(state): State, Path(id): Path, + ) -> HtmlResult { + let Some(event) = Event::lookup_by_id(&state.db, id).await? else { + bail_not_found!() + }; + + #[derive(Template, WebTemplate)] + #[template(path = "events/edit_confirmation.html")] + struct EditConfirmationHtml { + user: Option, + event: Event, + editor: Editor, + } + Ok(EditConfirmationHtml { + user: Some(user), + event: event.clone(), + editor: Editor { + url: "/events/{id}/confirmation/edit", + snapshot_prefix: "event/confirmation", + entity_id: Some(event.id), + content: match (event.confirmation_html, event.confirmation_updated_at) { + (Some(html), Some(updated_at)) => Some(EditorContent { html, updated_at }), + _ => None, + }, + }, + } + .into_response()) + } + + // Edit confirmation form. + #[derive(serde::Deserialize)] + pub struct EditConfirmationForm { + id: i64, + subject: String, + content: String, + } + #[derive(serde::Serialize)] + pub struct EditConfirmationResponse { + id: Option, + updated_at: Option, + error: Option, + } + pub async fn edit_confirmation_form( + State(state): State, Form(form): Form, + ) -> JsonResult { + let Some(event) = Event::lookup_by_id(&state.db, form.id).await? else { + bail_not_found!(); + }; + + let updated_at = Event::update_confirmation(&state.db, event.id, form.subject, form.content).await?; + + Ok(Json(EditConfirmationResponse { + id: Some(event.id), + updated_at: Some(updated_at.and_utc().timestamp_millis()), + error: None, + })) + } + + // Preview confirmation page. + pub async fn preview_confirmation_page( + State(state): State, Path(id): Path, + ) -> HtmlResult { + let Some(event) = Event::lookup_by_id(&state.db, id).await? else { + bail_not_found!() + }; + let flyer = EventFlyer::lookup(&state.db, event.id).await?; + + #[derive(Template, WebTemplate)] + #[template(path = "emails/event_confirmation.html")] + struct PreviewConfirmationHtml { + email_id: i64, + event: Event, + token: String, + flyer: Option, + } + Ok( + PreviewConfirmationHtml { email_id: 0, event: event.clone(), token: "xxxxxxxx".into(), flyer } + .into_response(), + ) + } + + // Edit dayof page. + pub async fn edit_dayof_page( + user: User, State(state): State, Path(id): Path, + ) -> HtmlResult { + let Some(event) = Event::lookup_by_id(&state.db, id).await? else { + bail_not_found!() + }; + + #[derive(Template, WebTemplate)] + #[template(path = "events/edit_dayof.html")] + struct EditDayofHtml { + user: Option, + event: Event, + editor: Editor, + } + Ok(EditDayofHtml { + user: Some(user), + event: event.clone(), + editor: Editor { + url: "/events/{id}/dayof/edit", + snapshot_prefix: "event/dayof", + entity_id: Some(event.id), + content: match (event.dayof_html, event.dayof_updated_at) { + (Some(html), Some(updated_at)) => Some(EditorContent { html, updated_at }), + _ => None, + }, + }, + } + .into_response()) + } + + // Edit dayof form. + #[derive(serde::Deserialize)] + pub struct EditDayofForm { + id: i64, + subject: String, + content: String, + } + #[derive(serde::Serialize)] + pub struct EditDayofResponse { + id: Option, + updated_at: Option, + error: Option, + } + pub async fn edit_dayof_form( + State(state): State, Form(form): Form, + ) -> JsonResult { + let Some(event) = Event::lookup_by_id(&state.db, form.id).await? else { + bail_not_found!(); + }; + + let updated_at = Event::update_dayof(&state.db, event.id, form.subject, form.content).await?; + + Ok(Json(EditDayofResponse { + id: Some(event.id), + updated_at: Some(updated_at.and_utc().timestamp_millis()), + error: None, + })) + } + + #[derive(Template, WebTemplate)] + #[template(path = "emails/event_dayof.html")] + struct DayofEmailHtml { + email_id: i64, + event: Event, + flyer: Option, + } + // Preview dayof page. + pub async fn preview_dayof_page(State(state): State, Path(id): Path) -> HtmlResult { + let Some(event) = Event::lookup_by_id(&state.db, id).await? else { + bail_not_found!() + }; + let flyer = EventFlyer::lookup(&state.db, event.id).await?; + + Ok(DayofEmailHtml { email_id: 0, event: event.clone(), flyer }.into_response()) + } + + /// Display the form to send a post. + pub async fn send_dayof_page( + user: User, State(state): State, Path(id): Path, + ) -> HtmlResult { + let Some(event) = Event::lookup_by_id(&state.db, id).await? else { + bail_not_found!() + }; + + #[derive(sqlx::FromRow)] + struct Counts { + count: i64, + sent: i64, + } + let list = sqlx::query_as!( + Counts, + r#" + SELECT + COUNT(r.user_id) AS count, + COALESCE(SUM( + CASE WHEN EXISTS ( + SELECT 1 + FROM emails e + WHERE e.kind = ? + AND e.user_id = r.user_id + AND e.event_id = rs.event_id + AND e.sent_at IS NOT NULL + ) + THEN 1 ELSE 0 END + ), 0) AS sent + FROM rsvps r + JOIN rsvp_sessions rs ON rs.id = r.session_id + WHERE rs.event_id = ? + AND (rs.status = ? OR rs.status = ?) + "#, + Email::EVENT_DAYOF, + event.id, + RsvpSession::PAYMENT_PENDING, + RsvpSession::PAYMENT_CONFIRMED, + ) + .fetch_one(&state.db) + .await?; + + #[derive(Template, WebTemplate)] + #[template(path = "events/send_dayof.html")] + struct SendHtml { + user: Option, + list: Counts, + event: Event, + ratelimit: usize, + } + let ratelimit = state.config.email.ratelimit; + Ok(SendHtml { user: Some(user), event, list, ratelimit }.into_response()) + } + + pub async fn send_dayof_form(State(state): State, Path(id): Path) -> HtmlResult { + let Some(event) = Event::lookup_by_id(&state.db, id).await? else { + bail_not_found!(); + }; + + let emails = Email::create_send_dayof_batch(&state.db, event.id).await?; + let flyer = EventFlyer::lookup(&state.db, event.id).await?; + + let mut email_template = DayofEmailHtml { email_id: 0, event: event.clone(), flyer }; + let mut messages = vec![]; + let mut email_ids = vec![]; + for Email { id, address, sent_at, .. } in emails { + if sent_at.is_some() { + continue; + } + + email_template.email_id = id; + + let from = &state.config.email.from; + let reply_to = config().email.contact_to.as_ref().unwrap_or(from); + let message = state + .mailer + .builder() + .to(address.parse().unwrap()) + .reply_to(reply_to.clone()) + .subject(event.dayof_subject.as_deref().expect("missing dayof_subject")) + .header(lettre::message::header::ContentType::TEXT_HTML) + .body(email_template.render()?) + .unwrap(); + + messages.push(message); + email_ids.push(id); + } + + event.mark_sent_dayof(&state.db).await?; + + let email_ids = futures::stream::iter(email_ids); + let results = state.mailer.send_batch(Arc::clone(&state), messages).await; + + let body = Body::from_stream(async_stream::stream! { + let mut stream = Box::pin(results.zip(email_ids)); + while let Some((progress, email_id)) = stream.next().await { + let json = match progress { + Ok(p) => { + Email::mark_sent(&state.db, email_id).await?; + json!({"sent": p.sent, "remaining": p.remaining}) + } + Err(e) => { + let e = e.message(); + Email::mark_error(&state.db, email_id, e).await?; + json!({"error": e}) + } + }.to_string(); + yield Ok::<_, AnyError>(format!("{json}\n")); + } + }); + + Ok(body.into_response()) + } + + // Edit description page. + pub async fn edit_description_page( + user: User, State(state): State, Path(id): Path, + ) -> HtmlResult { + let Some(event) = Event::lookup_by_id(&state.db, id).await? else { + bail_not_found!() + }; + + #[derive(Template, WebTemplate)] + #[template(path = "events/edit_description.html")] + struct EditDescriptionHtml { + user: Option, + event: Event, + editor: Editor, + } + Ok(EditDescriptionHtml { + user: Some(user), + event: event.clone(), + editor: Editor { + url: "/events/{id}/description/edit", + snapshot_prefix: "event/description", + entity_id: Some(event.id), + content: match (event.description_html, event.description_updated_at) { + (Some(html), Some(updated_at)) => Some(EditorContent { html, updated_at }), + _ => None, + }, + }, + } + .into_response()) + } + + // Edit description form. + #[derive(serde::Deserialize)] + pub struct EditDescriptionForm { + id: i64, + content: String, + } + #[derive(serde::Serialize)] + pub struct EditDescriptionResponse { + id: Option, + updated_at: Option, + error: Option, + } + pub async fn edit_description_form( + State(state): State, Form(form): Form, + ) -> JsonResult { + let Some(event) = Event::lookup_by_id(&state.db, form.id).await? else { + bail_not_found!(); + }; + + let updated_at = Event::update_description(&state.db, event.id, form.content).await?; + + Ok(Json(EditDescriptionResponse { + id: Some(event.id), + updated_at: Some(updated_at.and_utc().timestamp_millis()), + error: None, + })) + } + + /// View an event. + pub async fn attendees_page( + user: User, State(state): State, Path(slug): Path, + ) -> HtmlResult { + let event = Event::lookup_by_slug(&state.db, &slug).await?.ok_or_else(not_found)?; + let rsvps = Rsvp::list_for_admin_attendees(&state.db, event.id).await?; + + #[derive(Template, WebTemplate)] + #[template(path = "events/attendees.html")] + struct Html { + pub user: Option, + event: Event, + rsvp_count: usize, + total_contributions: i64, + rsvps: Vec, + } + + let rsvp_count = rsvps.len(); + let total_contributions = rsvps.iter().map(|r| r.contribution).sum(); + Ok(Html { user: Some(user), event, rsvp_count, total_contributions, rsvps }.into_response()) + } + + /// Handle delete submission. + pub async fn delete_form(State(state): State, Path(slug): Path) -> HtmlResult { + let event = Event::lookup_by_slug(&state.db, &slug).await?.ok_or_else(not_found)?; + Event::delete(&state.db, event.id).await?; + Ok(Redirect::to("/events").into_response()) + } + + /// Handle duplicate submission. + pub async fn duplicate_form(State(state): State, Path(slug): Path) -> HtmlResult { + let event = Event::lookup_by_slug(&state.db, &slug).await?.ok_or_else(not_found)?; + let (_, new_slug) = Event::duplicate(&state.db, event.id).await?; + Ok(Redirect::to(&format!("/events/{new_slug}/edit")).into_response()) + } + + #[derive(serde::Deserialize)] + pub struct AttendeePath { + slug: String, + user_id: i64, + } + + #[derive(serde::Deserialize)] + pub struct CheckinQuery { + #[serde(default)] + manual: bool, + } + + pub async fn set_checkin( + State(state): State, Path(path): Path, + Query(query): Query, + ) -> JsonResult<()> { + let event = Event::lookup_by_slug(&state.db, &path.slug).await?.ok_or_else(not_found)?; + if query.manual { + ManualRsvp::set_checkin_at(&state.db, event.id, path.user_id).await?; + } else { + Rsvp::set_checkin_at_for_event(&state.db, event.id, path.user_id).await?; + } + Ok(Json(())) + } + + pub async fn clear_checkin( + State(state): State, Path(path): Path, + Query(query): Query, + ) -> JsonResult<()> { + let event = Event::lookup_by_slug(&state.db, &path.slug).await?.ok_or_else(not_found)?; + if query.manual { + ManualRsvp::clear_checkin_at(&state.db, event.id, path.user_id).await?; + } else { + Rsvp::clear_checkin_at_for_event(&state.db, event.id, path.user_id).await?; + } + Ok(Json(())) + } + + pub async fn delete_attendee( + State(state): State, Path(path): Path, + Query(query): Query, + ) -> JsonResult<()> { + let event = Event::lookup_by_slug(&state.db, &path.slug).await?.ok_or_else(not_found)?; + if query.manual { + ManualRsvp::delete(&state.db, event.id, path.user_id).await?; + } else { + Rsvp::delete_for_event(&state.db, event.id, path.user_id).await?; + } + Ok(Json(())) + } + + /// Display the form to add a manual attendee. + pub async fn add_attendee_page( + user: User, State(state): State, Path(slug): Path, + ) -> HtmlResult { + let event = Event::lookup_by_slug(&state.db, &slug).await?.ok_or_else(not_found)?; + + #[derive(Template, WebTemplate)] + #[template(path = "events/attendees_add.html")] + struct Html { + user: Option, + event: Event, + } + Ok(Html { user: Some(user), event }.into_response()) + } + + #[derive(serde::Deserialize)] + pub struct AddAttendeeForm { + first_name: String, + last_name: String, + email: String, + } + + /// Handle add attendee form submission. + pub async fn add_attendee_form( + admin: User, State(state): State, Path(slug): Path, + Form(form): Form, + ) -> HtmlResult { + let event = Event::lookup_by_slug(&state.db, &slug).await?.ok_or_else(not_found)?; + + let user = User::update_or_create( + &state.db, + &CreateUser { + first_name: Some(form.first_name), + last_name: Some(form.last_name), + email: form.email, + phone: None, + }, + ) + .await?; + + // Check if already has an RSVP (manual or regular) for this event + if ManualRsvp::exists(&state.db, event.id, user.id).await? + || Rsvp::exists_for_event(&state.db, event.id, user.id).await? + { + return Ok(Redirect::to(&format!("/events/{slug}/attendees")).into_response()); + } + + // Create manual RSVP + ManualRsvp::create(&state.db, event.id, user.id, admin.id).await?; + + Ok(Redirect::to(&format!("/events/{slug}/attendees")).into_response()) + } +} + +mod rsvp { + use std::collections::HashSet; + + use super::*; + use crate::app::events::rsvp::parse::ParsedAttendee; + use crate::db::list::List; + use crate::db::rsvp::{AttendeeRsvp, ContributionRsvp, CreateRsvp, EventRsvp, Rsvp}; + use crate::db::rsvp_session::RsvpSession; + use crate::db::user::CreateUser; + use crate::utils::sentry; + + #[derive(Template, WebTemplate)] + #[template(path = "error_simple.html")] + struct ErrorHtml { + user: Option, + message: String, + } + + #[derive(Template, WebTemplate)] + #[template(path = "message_simple.html")] + struct MessageHtml { + user: Option, + title: String, + message: String, + } + + /// Create an RSVP session after a user clicks the RSVP button for an event. + pub async fn rsvp_form( + session: Option, State(state): State, Path(slug): Path, + ) -> HtmlResult { + let event = Event::lookup_by_slug(&state.db, &slug).await?.ok_or_else(not_found)?; + if !validate::registration_open(&event) { + return goto::error_registration_closed(&state.db, &None).await; + } + + match event.guest_list_id { + None => goto::selection_page(&state.db, &None, &session, &event).await, + Some(guest_list_id) => match session { + Some(session) => { + if let Some(user_id) = session.user_id + && List::has_user_id(&state.db, guest_list_id, user_id).await? + { + goto::selection_page(&state.db, &None, &Some(session), &event).await + } else { + goto::guestlist_page(&event) + } + } + _ => goto::guestlist_page(&event), + }, + } + } + + // Display the "Are you on the list?" page + pub async fn guestlist_page(State(state): State, Path(slug): Path) -> HtmlResult { + let event = Event::lookup_by_slug(&state.db, &slug).await?.ok_or_else(not_found)?; + if !validate::registration_open(&event) { + return goto::error_registration_closed(&state.db, &None).await; + } + + let _guest_list_id = event.guest_list_id.ok_or_else(invalid)?; + + #[derive(Template, WebTemplate)] + #[template(path = "events/rsvp_guestlist.html")] + struct GuestlistHtml { + user: Option, + slug: String, + } + Ok(GuestlistHtml { user: None, slug }.into_response()) + } + + // Handle submission of the "Are you on the list?" form + #[derive(Debug, serde::Deserialize)] + pub struct GuestlistForm { + email: String, + } + pub async fn guestlist_form( + mut session: Option, State(state): State, Path(slug): Path, + Form(form): Form, + ) -> HtmlResult { + let event = Event::lookup_by_slug(&state.db, &slug).await?.ok_or_else(not_found)?; + let guest_list_id = event.guest_list_id.ok_or_else(invalid)?; + if !validate::registration_open(&event) { + return goto::error_registration_closed(&state.db, &None).await; + } + + // If they're on the list, there must be a corresponding user. + let Some(user) = User::lookup_by_email(&state.db, &form.email).await? else { + return goto::error_not_on_guestlist(); + }; + let primary_user = CreateUser { + email: user.email.clone(), + first_name: user.first_name.clone(), + last_name: user.last_name.clone(), + phone: user.phone.clone(), + }; + + match List::has_user_id(&state.db, guest_list_id, user.id).await? { + true => { + tracing::info!( + "Guestlist check passed with event_id={} user_id={} user_email={:?}", + event.id, + user.id, + user.email + ); + + // Check for conflicts + let other_users = + Rsvp::list_reserved_users_for_event(&state.db, &event, session.as_ref()).await?; + use validate::Conflict; + if let Some(Conflict::Guest { email, status } | Conflict::Primary { email, status }) = + validate::no_conflicts(&other_users, &primary_user, &[]) + { + tracing::info!( + "RSVP conflict detected with event_id={} conflict_email={email:?} status={status:?}", + event.id + ); + return goto::error_conflict(&email, &status); + } + + // Set user on session if already exists + if let Some(session) = session.as_mut() { + session.set_user(&state.db, &user).await?; + } + + goto::selection_page(&state.db, &Some(user), &session, &event).await + } + false => goto::error_not_on_guestlist(), + } + } + + // Display the "Choose a contribution" page + pub async fn selection_page( + session: RsvpSession, State(state): State, Path(slug): Path, + ) -> HtmlResult { + let event = Event::lookup_by_slug(&state.db, &slug).await?.ok_or_else(not_found)?; + if !validate::registration_open(&event) { + return goto::error_registration_closed(&state.db, &Some(session)).await; + } + + let user = session.user(&state.db).await?; + let spots = Spot::list_for_event(&state.db, event.id).await?; + + let our_rsvps = Rsvp::list_for_session(&state.db, session.id).await?; + let mut our_qtys = HashMap::default(); + let mut our_contributions = HashMap::default(); + for rsvp in our_rsvps { + *our_qtys.entry(rsvp.spot_id).or_default() += 1; + our_contributions.insert(rsvp.spot_id, rsvp.contribution); + } + + let other_rsvps = Rsvp::list_reserved_for_event(&state.db, &event, &session).await?; + let limits = event.compute_limits(&user, &spots, &other_rsvps); + if limits.total_limit == 0 { + return goto::error_at_capacity(&state.db, &None).await; + } + let stats = Spot::stats(&spots, &other_rsvps); + + #[derive(Template, WebTemplate)] + #[template(path = "events/rsvp_selection.html")] + struct SelectionHtml { + event: Event, + spots: Vec, + our_qtys: HashMap, + our_contributions: HashMap, + limits: EventLimits, + stats: SpotStats, + } + Ok(SelectionHtml { event, spots, our_qtys, our_contributions, limits, stats }.into_response()) + } + + // Handle submission of the "Choose a contribution" form + #[derive(Debug, serde::Deserialize)] + pub struct SelectionForm { + rsvps: String, + } + pub async fn selection_form( + mut session: RsvpSession, State(state): State, Path(slug): Path, + Form(form): Form, + ) -> HtmlResult { + let event = Event::lookup_by_slug(&state.db, &slug).await?.ok_or_else(not_found)?; + if !validate::registration_open(&event) { + return goto::error_registration_closed(&state.db, &Some(session)).await; + } + + let user = session.user(&state.db).await?; + let spots = Spot::list_for_event(&state.db, event.id).await?; + + // Parse and validate form + let our_rsvps = parse::selection_form(&spots, &form.rsvps).map_err(|e| { + sentry::report(format!("selection_form(): session={} form={form:?}: {e}", session.token)); + invalid() + })?; + + // Verify limits + let other_rsvps = Rsvp::list_reserved_for_event(&state.db, &event, &session).await?; + let limits = event.compute_limits(&user, &spots, &other_rsvps); + if limits.total_limit == 0 { + return goto::error_at_capacity(&state.db, &None).await; + } + if !validate::within_limits(&limits, &our_rsvps) { + return goto::error_spot_taken(&state.db, &session).await; + } + + // Delete any old and create new RSVPs + Rsvp::delete_for_session(&state.db, session.id).await?; + for rsvp in our_rsvps { + Rsvp::create( + &state.db, + CreateRsvp { + session_id: session.id, + spot_id: rsvp.spot_id, + contribution: rsvp.contribution, + user_id: None, + user_version: None, + }, + ) + .await?; + } + session.clear_stripe_client_secret(&state.db).await?; + session.set_status(&state.db, RsvpSession::ATTENDEES).await?; + + // TODO: skip to /contribution if only one spot and RsvpSession already has an associated user + goto::attendees_page(&event) + } + + #[derive(PartialEq)] + enum AttendeesMode { + Create, + Edit, + } + #[derive(Template, WebTemplate)] + #[template(path = "events/rsvp_attendees.html")] + struct AttendeesHtml { + mode: AttendeesMode, + event: Event, + user: Option, + session: RsvpSession, + attendees: Vec, + price: i64, + } + // Display the "Who will be attending?" page after submitting spots + pub async fn attendees_page( + session: RsvpSession, State(state): State, Path(slug): Path, + ) -> HtmlResult { + let user = session.user(&state.db).await?; + let event = Event::lookup_by_slug(&state.db, &slug).await?.ok_or_else(not_found)?; + if !validate::registration_open(&event) { + return goto::error_registration_closed(&state.db, &Some(session)).await; + } + + let attendees = Rsvp::list_for_attendees(&state.db, session.id).await?; + let price = attendees.iter().map(|r| r.contribution).sum::(); + let mode = AttendeesMode::Create; + Ok(AttendeesHtml { mode, event, user, session, attendees, price }.into_response()) + } + + // Handle submission of the "Who will be attending?" form + #[derive(Debug, serde::Deserialize)] + pub struct AttendeesForm { + attendees: String, + } + pub async fn attendees_form( + mut our_session: RsvpSession, State(state): State, Path(slug): Path, + Form(form): Form, + ) -> HtmlResult { + let event = Event::lookup_by_slug(&state.db, &slug).await?.ok_or_else(not_found)?; + if !validate::registration_open(&event) { + return goto::error_registration_closed(&state.db, &Some(our_session)).await; + } + + let user = our_session.user(&state.db).await?; + let spots = Spot::list_for_event(&state.db, event.id).await?; + let our_rsvps = Rsvp::list_for_session(&state.db, our_session.id).await?; + + // Parse and validate form + let (primary_attendee, guest_attendees) = + parse::attendees_form(&user, &our_rsvps, &form.attendees).await.map_err(|e| { + sentry::report(format!("attendees_form(): session={} form={form:?}: {e}", our_session.token)); + invalid() + })?; + // Collect all users + let primary_user = primary_attendee.user.clone(); + let guest_users = guest_attendees.iter().map(|a| a.user.clone()).collect::>(); + let mut all_users = guest_users.clone(); + all_users.push(primary_user.clone()); + + // Check for conflicts + let other_users = Rsvp::list_reserved_users_for_event(&state.db, &event, Some(&our_session)).await?; + if let Some(conflict) = validate::no_conflicts(&other_users, &primary_user, &guest_users) { + use validate::Conflict; + match conflict { + // For guest conflicts, always show an error. + Conflict::Guest { email, status } => return goto::error_conflict(&email, &status), + // For primary conflicts... + Conflict::Primary { email, status } => match status.as_str() { + // If in a draft status, "take it over" by deleting the conflicting session + RsvpSession::ATTENDEES | RsvpSession::SELECTION => { + our_session.takeover_for_event(&state.db, &event, &email).await?; + } + // If reserved, show an error. + RsvpSession::CONTRIBUTION + | RsvpSession::PAYMENT_PENDING + | RsvpSession::PAYMENT_CONFIRMED => { + return goto::error_conflict(&email, &status); + } + _ => unreachable!(), + }, + } + } + + // Verify limits in case of preemption since `selection_form()` submission. + // Once we transition to CONTRIBUTION, our rsvps spots are held. + let other_rsvps = Rsvp::list_reserved_for_event(&state.db, &event, &our_session).await?; + let limits = event.compute_limits(&user, &spots, &other_rsvps); + if limits.total_limit == 0 { + return goto::error_at_capacity(&state.db, &None).await; + } + if !validate::within_limits(&limits, &our_rsvps) { + return goto::error_spot_taken(&state.db, &our_session).await; + } + + // Create and store primary user on RsvpSession and Rsvp + let primary_user = User::update_or_create(&state.db, &primary_user).await?; + our_session.set_user(&state.db, &primary_user).await?; + Rsvp::set_user(&state.db, primary_attendee.rsvp_id, &primary_user).await?; + + // Create and store users on guest Rsvps + for ParsedAttendee { rsvp_id, user } in guest_attendees { + let user = User::update_or_create(&state.db, &user).await?; + Rsvp::set_user(&state.db, rsvp_id, &user).await?; + } + + our_session.set_status(&state.db, RsvpSession::CONTRIBUTION).await?; + goto::contribution_page(&event) + } + + // Display the "Make your contribution" page after submitting attendees + pub async fn contribution_page( + mut session: RsvpSession, State(state): State, Path(slug): Path, + ) -> HtmlResult { + let event = Event::lookup_by_slug(&state.db, &slug).await?.ok_or_else(not_found)?; + if !validate::registration_open(&event) { + return goto::error_registration_closed(&state.db, &Some(session)).await; + } + + // A user is guaranteed to exist, since either: + // * There already was one in rsvp_form() and we redirected straight here (TODO, we don't redirect yet) + // * We've collected their info and just linked one in attendees_form() + let user = User::lookup_by_id(&state.db, session.user_id.unwrap()).await?.unwrap(); + let rsvps = Rsvp::list_for_contributions(&state.db, session.id).await?; + + let price = rsvps.iter().map(|r| r.contribution).sum(); + if price > 0 { + let line_items = session.line_items(&rsvps)?; + let return_url = format!("/e/{slug}/rsvp/manage?reservation={}", session.token); + + // Clear expired stripe sessions (older than 14 minutes) + if session.stripe_client_secret.is_some() && session.is_stripe_expired() { + session.clear_stripe_client_secret(&state.db).await?; + } + + if session.stripe_client_secret.is_none() { + let stripe_client_secret = state + .stripe + .create_session(session.id, &user.email, line_items, return_url) + .await?; + + session.set_stripe_client_secret(&state.db, &stripe_client_secret).await?; + } + } + + #[derive(Template, WebTemplate)] + #[template(path = "events/rsvp_contribution.html")] + struct ContributionHtml { + event: Event, + session: RsvpSession, + rsvps: Vec, + price: i64, + stripe_publishable_key: String, + } + Ok(ContributionHtml { + event, + session, + rsvps, + price, + stripe_publishable_key: state.config.stripe.publishable_key.clone(), + } + .into_response()) + } + + // Handle submission of $0 RSVPs. + pub async fn contribution_form( + State(state): State, session: RsvpSession, Path(slug): Path, + ) -> HtmlResult { + let event = Event::lookup_by_slug(&state.db, &slug).await?.ok_or_else(not_found)?; + if !validate::registration_open(&event) { + return goto::error_registration_closed(&state.db, &Some(session)).await; + } + + let rsvps = Rsvp::list_for_contributions(&state.db, session.id).await?; + let price: i64 = rsvps.iter().map(|r| r.contribution).sum(); + match price { + 0 => session.set_status(&state.db, RsvpSession::PAYMENT_CONFIRMED).await?, + _ => bail_invalid!(), + } + + // Send confirmation email + let user_id = session.user_id.ok_or_else(invalid)?; + let user = User::lookup_by_id(&state.db, user_id).await?.ok_or_else(invalid)?; + + if !Email::have_sent_confirmation(&state.db, event.id, user_id).await? { + let email = Email::create_confirmation(&state.db, event.id, user_id).await?; + let flyer = EventFlyer::lookup(&state.db, event.id).await?; + + #[derive(Template, WebTemplate)] + #[template(path = "emails/event_confirmation.html")] + struct ConfirmationEmailHtml { + email_id: i64, + event: Event, + token: String, + flyer: Option, + } + + let from = &state.config.email.from; + let reply_to = state.config.email.contact_to.as_ref().unwrap_or(from); + let subject = event + .confirmation_subject + .clone() + .unwrap_or_else(|| format!("Confirmation for {}", event.title)); + let message = state + .mailer + .builder() + .to(user.email.parse().unwrap()) + .reply_to(reply_to.clone()) + .subject(subject) + .header(lettre::message::header::ContentType::TEXT_HTML) + .body( + ConfirmationEmailHtml { + email_id: email.id, + event: event.clone(), + token: session.token.clone(), + flyer, + } + .render()?, + ) + .unwrap(); + + match state.mailer.send(&message).await { + Ok(_) => { + Email::mark_sent(&state.db, email.id).await?; + tracing::info!("Confirmation for event_id={} sent to email={:?}", event.id, user.email); + } + Err(e) => { + let e = e.message(); + Email::mark_error(&state.db, email.id, e).await?; + let message = format!( + "Error sending confirmation for event_id={} to email={:?}: {e}", + event.id, user.email + ); + tracing::error!(message); + sentry::report(message); + } + }; + } + + Ok(Redirect::to(&format!("/e/{slug}/rsvp/manage?reservation={}", &session.token)).into_response()) + } + + #[derive(serde::Deserialize)] + pub struct SessionQuery { + reservation: String, + } + // Show the "Manage your RSVP" page. + pub async fn manage_page( + user: Option, State(state): State, Query(query): Query, + Path(slug): Path, + ) -> HtmlResult { + let Some(session) = RsvpSession::lookup_by_token(&state.db, &query.reservation).await? else { + let error = ErrorHtml { user: user.clone(), message: "Reservation not found.".into() }; + return Ok(error.into_response()); + }; + let Some(user_id) = session.user_id else { + bail_invalid!() + }; + let Some(session_user) = User::lookup_by_id(&state.db, user_id).await? else { + bail_invalid!() + }; + + let event = Event::lookup_by_slug(&state.db, &slug).await?.ok_or_else(not_found)?; + let flyer = EventFlyer::lookup(&state.db, event.id).await?; + + match session.status.as_str() { + RsvpSession::SELECTION => { + return goto::selection_page(&state.db, &None, &Some(session), &event).await; + } + RsvpSession::ATTENDEES => return goto::attendees_page(&event), + // If you get here, we hold your spot and assume payment is coming later via webhook. + // This is technically exploitable, but we could check for still unpaid rsvps at event start. + RsvpSession::CONTRIBUTION => session.set_status(&state.db, RsvpSession::PAYMENT_PENDING).await?, + // If pending or confirmed, you're good. + RsvpSession::PAYMENT_PENDING | RsvpSession::PAYMENT_CONFIRMED => {} + _ => unreachable!(), + } + + if !Email::have_sent_confirmation(&state.db, session.event_id, user_id).await? { + let email = Email::create_confirmation(&state.db, session.event_id, user_id).await?; + let flyer = EventFlyer::lookup(&state.db, event.id).await?; + + #[derive(Template, WebTemplate)] + #[template(path = "emails/event_confirmation.html")] + struct ConfirmationEmailHtml { + email_id: i64, + event: Event, + token: String, + flyer: Option, + } + + let from = &state.config.email.from; + let reply_to = state.config.email.contact_to.as_ref().unwrap_or(from); + let message = state + .mailer + .builder() + .to(session_user.email.parse().unwrap()) + .reply_to(reply_to.clone()) + .subject( + event + .confirmation_subject + .clone() + .unwrap_or_else(|| format!("Confirmation for {}", event.title)), + ) + .header(lettre::message::header::ContentType::TEXT_HTML) + .body( + ConfirmationEmailHtml { + email_id: email.id, + event: event.clone(), + token: session.token.clone(), + flyer, + } + .render()?, + ) + .unwrap(); + + match state.mailer.send(&message).await { + Ok(_) => { + Email::mark_sent(&state.db, email.id).await?; + tracing::info!( + "Confirmation for event_id={} sent to email={:?}", + event.id, + session_user.email + ); + } + Err(e) => { + let e = e.message(); + Email::mark_error(&state.db, email.id, e).await?; + let message = format!( + "Error sending confirmation for event_id={} to email={:?}: {e}", + event.id, session_user.email + ); + tracing::error!(message); + sentry::report(message); + } + }; + + // If dayof email has been sent out, also send it to this new RSVP + if event.dayof_sent_at.is_some() { + let dayof_email = Email::create_send_dayof_single(&state.db, event.id, user_id).await?; + let dayof_flyer = EventFlyer::lookup(&state.db, event.id).await?; + + #[derive(Template, WebTemplate)] + #[template(path = "emails/event_dayof.html")] + struct DayofEmailHtml { + email_id: i64, + event: Event, + flyer: Option, + } + + let dayof_message = state + .mailer + .builder() + .to(session_user.email.parse().unwrap()) + .reply_to(reply_to.clone()) + .subject(event.dayof_subject.as_deref().expect("missing dayof_subject")) + .header(lettre::message::header::ContentType::TEXT_HTML) + .body( + DayofEmailHtml { email_id: dayof_email.id, event: event.clone(), flyer: dayof_flyer } + .render()?, + ) + .unwrap(); + + match state.mailer.send(&dayof_message).await { + Ok(_) => { + Email::mark_sent(&state.db, dayof_email.id).await?; + tracing::info!( + "Day-of for event_id={} sent to email={:?}", + event.id, + session_user.email + ); + } + Err(e) => { + let e = e.message(); + Email::mark_error(&state.db, email.id, e).await?; + let message = format!( + "Error sending day-of for event_id={} to email={:?}: {e}", + event.id, session_user.email + ); + tracing::error!(message); + sentry::report(message); + } + }; + } + } + + let rsvps = Rsvp::list_for_contributions(&state.db, session.id).await?; + let price = rsvps.iter().map(|r| r.contribution).sum::(); + + #[derive(Template, WebTemplate)] + #[template(path = "events/rsvp_manage.html")] + struct ManageHtml { + user: Option, + session: RsvpSession, + event: Event, + flyer: Option, + rsvps: Vec, + price: i64, + } + Ok(ManageHtml { user, session, event, flyer, rsvps, price }.into_response()) + } + // Show the "Manage your RSVP" page. + #[allow(unused)] + pub async fn temp_delete( + State(state): State, Query(query): Query, Path(slug): Path, + ) -> HtmlResult { + let session = RsvpSession::lookup_by_token(&state.db, &query.reservation) + .await? + .ok_or_else(not_found)?; + session.delete(&state.db).await?; + Ok(Redirect::to(&format!("/e/{slug}")).into_response()) + } + + // Show the editor for "Who will be attending?" page. + pub async fn edit_guests_page( + user: Option, State(state): State, Query(query): Query, + Path(slug): Path, + ) -> HtmlResult { + let event = Event::lookup_by_slug(&state.db, &slug).await?.ok_or_else(not_found)?; + let Some(session) = RsvpSession::lookup_by_token(&state.db, &query.reservation).await? else { + // A nonexistant session should never reach /edit, and a confirmed session should never be deleted. + bail_not_found!(); + }; + + let rsvps = Rsvp::list_for_attendees(&state.db, session.id).await?; + let mode = AttendeesMode::Edit; + Ok(AttendeesHtml { mode, event, user, session, attendees: rsvps, price: 0 }.into_response()) + } + pub async fn edit_guests_form( + State(state): State, Query(query): Query, Path(slug): Path, + Form(form): Form, + ) -> HtmlResult { + let event = Event::lookup_by_slug(&state.db, &slug).await?.ok_or_else(not_found)?; + let session = RsvpSession::lookup_by_token(&state.db, &query.reservation) + .await? + .ok_or_else(invalid)?; + let user = session.user(&state.db).await?; + let our_rsvps = Rsvp::list_for_session(&state.db, session.id).await?; + + // Parse and validate form. NOTE that we only allow editing guest info. + // The primary_attendee form is disabled on the frontend, and changes are ignored here. + let (primary_attendee, guest_attendees) = + parse::attendees_form(&user, &our_rsvps, &form.attendees).await.map_err(|e| { + sentry::report(format!("edit_form(): session={} form={form:?}: {e}", session.token)); + tracing::error!("{}", format!("edit_form(): session={} form={form:?}: {e}", session.token)); + invalid() + })?; + + // Check for conflicts + let primary_user = primary_attendee.user.clone(); + let guest_users = guest_attendees.iter().map(|a| a.user.clone()).collect::>(); + let other_users = Rsvp::list_reserved_users_for_event(&state.db, &event, Some(&session)).await?; + use validate::Conflict; + if let Some(Conflict::Guest { email, status } | Conflict::Primary { email, status }) = + validate::no_conflicts(&other_users, &primary_user, &guest_users) + { + return goto::error_conflict(&email, &status); + } + + // Update guests + for ParsedAttendee { rsvp_id, user } in guest_attendees { + let user = User::update_or_create(&state.db, &user).await?; + Rsvp::set_user(&state.db, rsvp_id, &user).await?; + } + + goto::manage_page(&session, &event) + } + + /// Helpers for changing RSVP session state and redirecting. + #[rustfmt::skip] + pub mod goto { + use super::*; + + pub fn guestlist_page(event: &Event) -> HtmlResult { + Ok(Redirect::to(&format!("/e/{}/rsvp/guestlist", &event.slug)).into_response()) + } + pub async fn selection_page(db: &Db, user: &Option, session: &Option, event: &Event) -> HtmlResult { + let headers = RsvpSession::get_or_create(db, user, session, event.id).await?; + Ok((headers, Redirect::to(&format!("/e/{}/rsvp/selection", &event.slug))).into_response()) + } + pub fn attendees_page(event: &Event) -> HtmlResult { + Ok(Redirect::to(&format!("/e/{}/rsvp/attendees", &event.slug)).into_response()) + } + pub fn contribution_page(event: &Event) -> HtmlResult { + Ok(Redirect::to(&format!("/e/{}/rsvp/contribution", &event.slug)).into_response()) + } + pub fn manage_page(session: &RsvpSession, event: &Event) -> HtmlResult { + Ok(Redirect::to(&format!("/e/{}/rsvp/manage?reservation={}", &event.slug, &session.token)).into_response()) + } + + pub fn error_not_on_guestlist() -> HtmlResult { + let error = ErrorHtml { user: None, message: "Sorry, you're not on the list.".into() }; + Ok(error.into_response()) + } + pub async fn error_at_capacity(db: &Db, session: &Option) -> HtmlResult { + if let Some(session) = session { + session.delete(db).await?; + } + Ok(MessageHtml { + user: None, + title: "Sorry".into(), + message: "This event has reached capacity.".into(), + } + .into_response()) + } + pub async fn error_registration_closed(db: &Db, session: &Option) -> HtmlResult { + if let Some(session) = session { + session.delete(db).await?; + } + Ok(MessageHtml { + user: None, + title: "Sorry".into(), + message: "Registration for this event is now closed.".into(), + } + .into_response()) + } + pub async fn error_spot_taken(db: &Db, session: &RsvpSession) -> HtmlResult { + session.delete(db).await?; + Ok(ErrorHtml { + user: None, + message: "Sorry, a spot you selected was taken. Please try again.".to_string(), + } + .into_response()) + } + pub fn error_conflict(email: &str, status: &str) -> HtmlResult { + let wording = match status { + RsvpSession::SELECTION | RsvpSession::ATTENDEES | RsvpSession::CONTRIBUTION => "is currently in the process of RSVPing", + RsvpSession::PAYMENT_PENDING | RsvpSession::PAYMENT_CONFIRMED => "has already RSVPed", + _ => unreachable!() + }; + Ok(ErrorHtml { + message: format!("Someone {wording} for {email}."), + user: None, + }.into_response()) + } + } + + mod validate { + use super::*; + use crate::db::rsvp::UserRsvp; + + pub fn registration_open(event: &Event) -> bool { + !event.closed + } + + /// Returns true if rsvps satisfy total and per-spot limits. + pub fn within_limits(limits: &EventLimits, rsvps: &[EventRsvp]) -> bool { + let total_qty = rsvps.len() as i64; + let mut spot_qtys: HashMap = HashMap::default(); + for rsvp in rsvps { + *spot_qtys.entry(rsvp.spot_id).or_default() += 1; + } + + if total_qty > limits.total_limit { + return false; + } + for (spot_id, spot_qty) in spot_qtys { + if spot_qty > *limits.spot_limits.get(&spot_id).unwrap_or(&0) { + return false; + } + } + + true + } + + pub enum Conflict { + Primary { email: String, status: String }, + Guest { email: String, status: String }, + } + #[rustfmt::skip] + pub fn no_conflicts( + other_users: &[UserRsvp], primary: &CreateUser, guests: &[CreateUser], + ) -> Option { + for other_user in other_users { + if primary.email == other_user.email { + return Some(Conflict::Primary { email: other_user.email.clone(), status: other_user.status.clone() }) + } + + for guest in guests { + if guest.email == other_user.email { + return Some(Conflict::Guest { email: other_user.email.clone(), status: other_user.status.clone() }); + } + } + } + None + } + } + + mod parse { + use super::*; + + #[derive(thiserror::Error, Debug)] + pub enum ParseSelectionError { + #[error("failed to parse request: {0}")] + Parse(#[from] serde_json::Error), + #[error("unknown spot_id={spot_id}")] + UnknownSpot { spot_id: i64 }, + + #[error("contribution is outside of range for spot_id={spot_id}")] + SpotRange { spot_id: i64 }, + } + pub fn selection_form( + spots: &[Spot], selection: &str, + ) -> Result, ParseSelectionError> { + type Error = ParseSelectionError; + + #[derive(Debug, serde::Deserialize)] + pub struct RsvpForm { + spot_id: i64, + qty: i64, + contribution: Option, + } + + let rsvps: Vec = serde_json::from_str(selection)?; + let mut parsed = vec![]; + + for rsvp in rsvps { + let spot_id = rsvp.spot_id; + let Some(spot) = spots.iter().find(|s| s.id == spot_id) else { + return Err(Error::UnknownSpot { spot_id }); + }; + + let contribution = match spot.kind.as_str() { + Spot::FIXED => spot.required_contribution.unwrap(), + Spot::VARIABLE => rsvp.contribution.unwrap(), + Spot::FREE => 0, + Spot::WORK => 0, + kind => panic!("unknown kind: {kind}"), + }; + if spot.kind == Spot::VARIABLE { + let min = spot.min_contribution.unwrap(); + let max = spot.max_contribution.unwrap(); + if !(min..=max).contains(&contribution) { + return Err(Error::SpotRange { spot_id }); + } + } + + for _ in 0..rsvp.qty { + parsed.push(EventRsvp { rsvp_id: 0, spot_id, contribution }) + } + } + + Ok(parsed) + } + + #[derive(Clone)] + pub struct ParsedAttendee { + pub rsvp_id: i64, + pub user: CreateUser, + } + #[derive(thiserror::Error, Debug)] + pub enum ParseAttendeesError { + #[error("failed to parse request: {0}")] + Parse(#[from] serde_json::Error), + + #[error("unknown or duplicate rsvp_id={rsvp_id}")] + UnknownOrDuplicateRsvp { rsvp_id: i64 }, + #[error("missing attendee for rsvp_ids={rsvp_ids:?}")] + MissingAttendee { rsvp_ids: Vec }, + #[error("missing attendee with is_me=true")] + MissingPrimary, + #[error("multiple attendees with is_me=true")] + MultiplePrimary, + #[error("modified attendee with is_me=true")] + PrimaryChanged, + #[error("invalid name: first={first_name:?} last={last_name:?}")] + InvalidName { first_name: String, last_name: String }, + #[error("invalid phone number: {phone}")] + InvalidPhone { phone: String }, + #[error("duplicate email: {email}")] + DuplicateEmail { email: String }, + #[error("duplicate phone: {phone}")] + DuplicatePhone { phone: String }, + } + pub async fn attendees_form( + session_user: &Option, rsvps: &[EventRsvp], attendees: &str, + ) -> Result<(ParsedAttendee, Vec), ParseAttendeesError> { + type Error = ParseAttendeesError; + + #[derive(Debug, serde::Deserialize)] + pub struct AttendeeForm { + rsvp_id: i64, + + first_name: String, + last_name: String, + email: String, + phone: Option, + + is_me: bool, + } + let attendees: Vec = serde_json::from_str(attendees)?; + + // Track available rsvp_ids, seen email/phones for duplicate detection + let mut remaining_rsvps: HashSet = HashSet::from_iter(rsvps.iter().map(|r| r.rsvp_id)); + let mut seen_emails: HashSet = HashSet::default(); + let mut seen_phones: HashSet = HashSet::default(); + + // Extract primary/guest attendees from form, and map to rsvps. + let mut primary_attendee = None; + let mut guest_attendees = vec![]; + for AttendeeForm { rsvp_id, first_name, last_name, email, phone, is_me } in attendees { + // Validate rsvp_id + if !remaining_rsvps.remove(&rsvp_id) { + return Err(Error::UnknownOrDuplicateRsvp { rsvp_id }); + } + + // Validate name + if first_name.is_empty() || last_name.is_empty() { + return Err(Error::InvalidName { first_name, last_name }); + } + + // Validate email/phone and check for duplicates + if !seen_emails.insert(email.clone()) { + return Err(Error::DuplicateEmail { email }); + } + let phone = parse_phone(phone)?; + if let Some(phone) = phone.clone() + && !seen_phones.insert(phone.clone()) + { + return Err(Error::DuplicatePhone { phone }); + } + + let user = + CreateUser { first_name: Some(first_name), last_name: Some(last_name), email, phone }; + let attendee = ParsedAttendee { rsvp_id, user }; + if is_me { + // Disallow changing is_me email when it's already set on the session (it's disabled on the frontend) + if session_user.as_ref().is_some_and(|u| attendee.user.email != u.email) { + return Err(Error::PrimaryChanged); + } + + match primary_attendee { + Some(_) => return Err(Error::MultiplePrimary), + None => primary_attendee = Some(attendee), + } + } else { + guest_attendees.push(attendee); + } + } + // Ensure exactle one primary attendee. + let Some(primary_attendee) = primary_attendee else { + return Err(Error::MissingPrimary); + }; + + // Ensure no remaining rsvps without an attendee specified + if !remaining_rsvps.is_empty() { + let rsvp_ids = remaining_rsvps.into_iter().collect(); + return Err(Error::MissingAttendee { rsvp_ids }); + } + + Ok((primary_attendee, guest_attendees)) + } + /// Normalize phone to E.164 format. + /// Empty string is ok (returns None). 10 digits assumes +1. 11-15 digits assumes leading +. + fn parse_phone(phone: Option) -> Result, ParseAttendeesError> { + let Some(phone) = phone else { return Ok(None) }; + if phone.trim().is_empty() { + return Ok(None); + }; + + let digits: String = phone.chars().filter(|c| c.is_ascii_digit()).collect(); + match digits.len() { + 10 => Ok(Some(format!("+1{digits}"))), + 11..=15 => Ok(Some(format!("+{digits}"))), + _ => Err(ParseAttendeesError::InvalidPhone { phone }), + } + } + } +} + +pub fn add_middleware(router: AxumRouter, state: SharedAppState) -> AxumRouter { + /// Middleware layer to lookup add an `RsvpSession` to the request if an rsvp_session token is present. + /// Also blocks RSVP pages (except manage/edit) when registration is closed. + pub async fn rsvp_session_middleware( + State(state): State, cookies: CookieJar, mut request: Request, next: Next, + ) -> HtmlResult { + let is_rsvp_path = request.uri().path().contains("/rsvp"); + + if let Some(token) = cookies.get("rsvp_session") + && let Some(session) = RsvpSession::lookup_by_token(&state.db, token.value()).await? + { + // Don't remove stale cookies if session is not found (e.g. it expired). + // They will be overwritten when a new session is created. + request.extensions_mut().insert(session); + } + + let mut res = next.run(request).await; + + if is_rsvp_path { + // Prevent browser from storing these stateful pages in the back-forward cache + res.headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + } + + Ok(res) + } + router.layer(axum::middleware::from_fn_with_state(state, rsvp_session_middleware)) +} + +/// Enable extracting an `Option` in an events handler matching /e/{slug}. +impl axum::extract::OptionalFromRequestParts for RsvpSession { + type Rejection = Infallible; + async fn from_request_parts( + parts: &mut Parts, _state: &SharedAppState, + ) -> Result, Self::Rejection> { + Ok(parts.extensions.get::().cloned()) + } +} +/// Enable extracting an `RsvpSession` in an events handler matching /e/{slug}. +/// * Redirects to /e/{slug} if no session is present. +/// * Redirects to /e/{slug}/rsvp/manage if rsvp is already completed. +impl axum::extract::FromRequestParts for RsvpSession { + type Rejection = Redirect; + async fn from_request_parts(parts: &mut Parts, _state: &SharedAppState) -> Result { + fn parse_slug(url: &str) -> Option<&str> { + let url = url.trim_start_matches('/'); + let (e, rest) = url.split_once('/')?; + match e { + "e" => { + let (slug, _rest) = rest.split_once('/')?; + Some(slug) + } + _ => None, + } + } + let Some(slug) = parse_slug(parts.uri.path()) else { + panic!( + "RsvpSession extractor used at path={:?} not matching /e/{{slug}}", + parts.uri.path() + ); + }; + + match parts.extensions.get::().cloned() { + Some(session) => match session.status.as_str() { + RsvpSession::SELECTION | RsvpSession::ATTENDEES | RsvpSession::CONTRIBUTION => Ok(session), + RsvpSession::PAYMENT_PENDING | RsvpSession::PAYMENT_CONFIRMED => { + match parts.uri.path().contains("manage") { + true => Ok(session), // avoid redirect loop + false => Err(Redirect::to(&format!( + "{}/e/{slug}/rsvp/manage?reservation={}", + config().app.url, + session.token + ))), + } + } + _ => unreachable!(), + }, + None => Err(Redirect::to(&format!("/e/{slug}"))), + } + } +} diff --git a/src/app/home.rs b/src/app/home.rs new file mode 100644 index 00000000..b955685e --- /dev/null +++ b/src/app/home.rs @@ -0,0 +1,51 @@ +use crate::db::event::Event; +use crate::prelude::*; + +/// Add all `home` routes to the router. +pub fn add_routes(router: AppRouter) -> AppRouter { + router + .public_routes(|r| { + r.route("/", get(home_page)) + .route("/past", get(past_page)) + .route("/sublet", get(sublet_page)) + }) + // TODO: Rethink roles, not WRITER. Template out buttons based on role. + .restricted_routes(User::WRITER, |r| r.route("/dashboard", get(dashboard_page))) +} + +#[derive(Template, WebTemplate)] +#[template(path = "home.html")] +struct HomeHtml { + user: Option, + events: Vec, + past: bool, +} + +#[derive(Template, WebTemplate)] +#[template(path = "dashboard.html")] +struct DashboardHtml { + user: Option, +} + +/// Display the front page. +async fn home_page(user: Option, State(state): State) -> HtmlResult { + Ok(HomeHtml { user, events: Event::list_upcoming(&state.db).await?, past: false }.into_response()) +} + +async fn past_page(user: Option, State(state): State) -> HtmlResult { + Ok(HomeHtml { user, events: Event::list_past(&state.db).await?, past: true }.into_response()) +} + +async fn dashboard_page(user: User) -> HtmlResult { + Ok(DashboardHtml { user: Some(user) }.into_response()) +} + +#[derive(Template, WebTemplate)] +#[template(path = "sublet.html")] +struct SubletHtml { + user: Option, +} + +async fn sublet_page(user: Option) -> HtmlResult { + Ok(SubletHtml { user }.into_response()) +} diff --git a/src/app/lists.rs b/src/app/lists.rs new file mode 100644 index 00000000..f83e16f7 --- /dev/null +++ b/src/app/lists.rs @@ -0,0 +1,190 @@ +use lettre::message::Mailbox; + +use crate::db::list::{List, UpdateList}; +use crate::prelude::*; + +/// Add all `lists` routes to the router. +#[rustfmt::skip] +pub fn add_routes(router: AppRouter) -> AppRouter { + router.public_routes(|r| { + r.route("/newsletter", get(newsletter_signup_page)) + .route("/lists/{id}/signup", get(signup_page).post(signup_form)) + }) + .restricted_routes(User::ADMIN, |r| { + r.route("/lists", get(list_lists_page)) + .route("/lists/new", get(create_list_page)) + .route("/lists/{id}", get(edit_list_page).post(edit_list_form)) + .route("/lists/{id}/{user_id}", delete(remove_list_member)) + }) +} + +#[derive(Template, WebTemplate)] +#[template(path = "lists/edit.html")] +struct ListEditHtml { + user: Option, + list: List, + members: Vec, +} + +/// Display a list of all lists +async fn list_lists_page(user: User, State(state): State) -> HtmlResult { + let lists = List::list(&state.db).await?; + + #[derive(Template, WebTemplate)] + #[template(path = "lists/list.html")] + struct Html { + user: Option, + lists: Vec, + } + Ok(Html { user: Some(user), lists }.into_response()) +} + +/// Display the form to view and edit a list. +async fn edit_list_page(user: User, State(state): State, Path(id): Path) -> HtmlResult { + let list = List::lookup_by_id(&state.db, id).await?.ok_or_else(not_found)?; + + let members = List::list_members(&state.db, id).await?; + + Ok(ListEditHtml { user: Some(user), list, members }.into_response()) +} + +/// Display the form to create a new list. +async fn create_list_page(user: User) -> HtmlResult { + let list = List { + id: 0, + name: "".into(), + description: "".into(), + created_at: Utc::now().naive_utc(), + updated_at: Utc::now().naive_utc(), + }; + + Ok(ListEditHtml { user: Some(user), list, members: vec![] }.into_response()) +} + +/// Process the form and create or edit a list. +async fn edit_list_form( + user: User, State(state): State, Form(form): Form, +) -> HtmlResult { + let id = match form.id { + Some(id) => { + List::update(&state.db, id, &form).await?; + id + } + None => List::create(&state.db, &form).await?, + }; + + // Parse one email per line, extracting from formats like "Name " or just "email" + let mut emails = Vec::new(); + for line in form.emails.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + // Try to find an email in the line - look for something with @ in it + let email = line + .split([' ', ',', '\t', '<', '>']) + .map(|s| s.trim()) + .find(|s| s.contains('@')); + match email { + Some(email) if email.parse::().is_ok() => { + emails.push(email.to_string()); + } + _ => { + return Ok(ErrorHtml { + user: Some(user), + title: "Invalid email".into(), + message: format!("Could not find a valid email address in line: '{line}'"), + context: None, + backtrace: None, + } + .into_response()); + } + } + } + if !emails.is_empty() { + let email_refs: Vec<&str> = emails.iter().map(|s| s.as_str()).collect(); + List::add_members(&state.db, id, &email_refs).await?; + } + + Ok(Redirect::to(&format!("/lists/{id}")).into_response()) +} + +async fn remove_list_member( + State(state): State, Path((id, user_id)): Path<(i64, i64)>, +) -> JsonResult<()> { + List::remove_member(&state.db, id, user_id).await?; + Ok(Json(())) +} + +/// Display the newsletter signup page. +// XXX: Hard coded to list with id=1. +pub async fn newsletter_signup_page(user: Option, State(state): State) -> HtmlResult { + signup_page(user, State(state), Path(1)).await +} + +/// Display the list signup page. +async fn signup_page( + user: Option, State(state): State, Path(list_id): Path, +) -> HtmlResult { + // XXX: Hard code only allow id 1 to be signed up to. + // A flag should be added to List whether it's public or not, and what the signup page looks like. + if list_id != 1 { + bail_unauthorized!() + } + + let Some(list) = List::lookup_by_id(&state.db, list_id).await? else { + bail_not_found!(); + }; + + #[derive(Template, WebTemplate)] + #[template(path = "lists/signup.html")] + struct Html { + user: Option, + list: List, + } + Ok(Html { user, list }.into_response()) +} + +/// Process the list signup form. +// +// XXX: We really should rate limit this. +async fn signup_form( + user: Option, State(state): State, Form(form): Form, +) -> HtmlResult { + // XXX: Hard code only allow id 1 to be signed up to. + // A flag should be added to List whether it's public or not, and what the signup page looks like. + if form.list_id != 1 { + bail_unauthorized!() + } + + let Some(list) = List::lookup_by_id(&state.db, form.list_id).await? else { + bail_not_found!(); + }; + + if List::has_email(&state.db, form.list_id, form.email.email.as_ref()).await? { + return Ok(ErrorHtml { + user, + title: "Error.".into(), + message: "You're already on the list!".into(), + context: None, + backtrace: None, + } + .into_response()); + } else { + List::add_members(&state.db, list.id, &[form.email.email.as_ref()]).await?; + } + + #[derive(Template, WebTemplate)] + #[template(path = "lists/confirmation.html")] + struct SuccessHtml { + user: Option, + list: List, + email: String, + } + Ok(SuccessHtml { user, list, email: state.config.email.from.email.to_string() }.into_response()) +} +#[derive(serde::Deserialize)] +struct NewsletterForm { + list_id: i64, + email: Mailbox, +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 4a0a617d..7a39fbb1 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,170 +1,104 @@ -use std::{sync::Arc, time::Duration}; - -use crate::utils::{config::*, db::Db, email::Email}; -use tera::Tera; - -use anyhow::Result; -use axum::{ - extract::{MatchedPath, Query, Request, State}, - http::{header, StatusCode}, - response::{Html, IntoResponse, Redirect, Response}, - routing::{get, post}, - Form, Router, -}; -use axum_extra::extract::CookieJar; -use lettre::message::Mailbox; -use tower_http::{services::ServeDir, trace::TraceLayer}; -use tracing::Span; - -#[derive(Clone)] -#[allow(unused)] -struct AppState { - config: Config, - templates: Tera, - db: Db, - mail: Email, +use axum::Router; +use axum::extract::DefaultBodyLimit; +use tower::ServiceBuilder; +use tower_http::compression::{self, CompressionLayer, Predicate}; + +use crate::prelude::*; +use crate::utils::cloudflare::Cloudflare; +use crate::utils::emailer::Emailer; +use crate::utils::stripe::Stripe; + +mod auth; +mod bulletin; +mod contact; +mod emails; +mod events; +mod home; +mod lists; +mod posts; +mod webhooks; + +pub struct AppState { + pub config: Config, + pub db: Db, + pub stripe: Stripe, + pub cloudflare: Cloudflare, + pub mailer: Emailer, } -pub async fn build(config: Config) -> Result { - let state = AppState { +pub async fn build(config: Config) -> Result<(Router<()>, SharedAppState)> { + let state = Arc::new(AppState { config: config.clone(), - templates: Tera::new("templates/*")?, - db: Db::connect(&config.app.db).await?, - mail: Email::connect(config.email).await?, + db: crate::db::init(&config.db).await?, + stripe: Stripe::new(&config), + cloudflare: Cloudflare::new(&config)?, + mailer: Emailer::connect(config.email).await?, + }); + + // Register business logic routes + let r = AppRouter::new(&state); + let r = home::add_routes(r); + let r = auth::add_routes(r); + let r = posts::add_routes(r); + let r = events::add_routes(r); + let r = lists::add_routes(r); + let r = emails::add_routes(r); + let r = webhooks::add_routes(r); + let r = contact::add_routes(r); + let r = bulletin::add_routes(r); + let (r, state) = r.finish(); + + // Register app-wide routes + #[cfg(debug_assertions)] + let r = { + use tower_http::services::ServeDir; + r.nest_service("/static", ServiceBuilder::new().service(ServeDir::new("frontend/static"))) }; - - let router = Router::new() - .route("/", get(home)) - .route("/login", post(login_form)) - .route("/login", get(login)) - .route("/register", get(register)) - .route("/register", post(register_form)) - .nest_service("/assets", ServeDir::new("assets")) - .layer( - TraceLayer::new_for_http() - .make_span_with(|req: &Request<_>| { - let path = match req.extensions().get::() { - Some(path) => path.as_str(), - None => req.uri().path(), - }; - tracing::info_span!("request", method = ?req.method(), path, status = tracing::field::Empty) - }) - .on_request(|_req: &Request<_>, _span: &Span| {}) - .on_response(|res: &Response, latency: Duration, span: &Span| { - span.record("status", res.status().as_u16()); - tracing::info!("handled in {latency:?}"); - }), - ) - .with_state(Arc::new(state)); - Ok(router) -} - -async fn home(State(state): State>, cookies: CookieJar) -> AppResult { - let mut ctx = tera::Context::new(); - ctx.insert("message", "Hello, world!"); - - if let Some(session_token) = cookies.get("session") { - let Some(user) = state.db.lookup_user_from_session_token(session_token.value()).await? else { - return Ok(StatusCode::FORBIDDEN.into_response()); + #[rustfmt::skip] + #[cfg(not(debug_assertions))] + let r = { + use tower_serve_static::{ServeFile, include_file}; + use tower_http::set_header::SetResponseHeaderLayer; + use tower_http::services::ServeDir; + use axum::http::{HeaderName, HeaderValue}; + + let nest_static = |r: Router>, urgency: u8, filename: &str, file: tower_serve_static::File| -> Router { + let service = ServiceBuilder::new() + .layer(SetResponseHeaderLayer::overriding( + header::CACHE_CONTROL, + HeaderValue::from_static("public, max-age=31536000, immutable"), + )) + .layer(SetResponseHeaderLayer::overriding( + HeaderName::from_static("priority"), + HeaderValue::from_str(&format!("u={urgency}")).unwrap(), + )) + .service(ServeFile::new(file)); + r.nest_service(&format!("/static/{filename}"), service) }; - ctx.insert("user", &user); - } - - let html = state.templates.render("home.tera.html", &ctx).unwrap(); - Ok(Html(html).into_response()) -} - -#[derive(serde::Deserialize)] -struct LoginForm { - email: Mailbox, -} -async fn login_form( - State(state): State>, - Form(form): Form, -) -> AppResult { - let login_token = state.db.create_login_token(&form.email).await?; - - let url = &state.config.app.url; - let url = match state.db.lookup_user_by_email(&form.email).await? { - Some(_) => format!("{url}/login?token={login_token}"), - None => format!("{url}/register?token={login_token}"), - }; - - let msg = state.mail.builder().to(form.email).body(url)?; - state.mail.send(msg).await?; - - Ok("Check your email!") -} -#[derive(serde::Deserialize)] -struct LoginQuery { - token: String, -} -async fn login(State(state): State>, Query(login): Query) -> AppResult { - let Some(user) = state.db.lookup_user_by_login_token(&login.token).await? else { - return Ok(StatusCode::FORBIDDEN.into_response()); + let r = nest_static(r, 0, "main.css", include_file!("/frontend/static/main.css")); + let r = nest_static(r, 4, "favicon.ico", include_file!("/frontend/static/favicon.ico")); + // Serve additional static files from disk + let r = r.nest_service("/static", ServiceBuilder::new().service(ServeDir::new("/home/lsd/static"))); + r }; - - let session_token = state.db.create_session_token(user.id).await?; - let headers = ( - // TODO: expiration date - [(header::SET_COOKIE, format!("session={session_token}; Secure; Secure"))], - Redirect::to(&state.config.app.url), + // For non-HTML pages without a , this is where the browser looks + let r = r.route("/favicon.ico", get(|| async { Redirect::to("/static/favicon.ico") })); + let r = r.fallback(|| async { Err::<(), HtmlError>(not_found().into()) }); + + // Register middleware + let r = auth::add_middleware(r, Arc::clone(&state)); + let r = events::add_middleware(r, Arc::clone(&state)); + let r = crate::utils::tracing::add_middleware(r); + let r = r.layer(DefaultBodyLimit::max(16 * 1024 * 1024)); // 16MB limit + let r = r.layer( + CompressionLayer::new().compress_when( + compression::DefaultPredicate::new() + .and(compression::predicate::NotForContentType::new("image/jpeg")) + .and(compression::predicate::NotForContentType::new("font/woff2")), + ), ); - Ok(headers.into_response()) -} + let r = r.with_state(Arc::clone(&state)); -#[derive(serde::Deserialize)] -struct RegisterQuery { - token: String, -} -async fn register( - State(state): State>, - Query(register): Query, -) -> AppResult { - let mut ctx = tera::Context::new(); - ctx.insert("token", ®ister.token); - let html = state.templates.render("register.tera.html", &ctx).unwrap(); - Ok(Html(html).into_response()) -} - -#[derive(serde::Deserialize)] -struct RegisterForm { - token: String, - first_name: String, - last_name: String, -} -async fn register_form( - State(state): State>, - Form(form): Form, -) -> AppResult { - let Some(email) = state.db.lookup_email_by_login_token(&form.token).await? else { - return Ok(StatusCode::FORBIDDEN.into_response()); - }; - - let user_id = state.db.create_user(&form.first_name, &form.last_name, &email).await?; - let session_token = state.db.create_session_token(user_id).await?; - - // TODO: expiration date on the cookie - let headers = ( - [(header::SET_COOKIE, format!("session={session_token}; Secure; Secure"))], - Redirect::to(&state.config.app.url), - ); - Ok(headers.into_response()) -} - -struct AppError(anyhow::Error); -type AppResult = Result; -impl IntoResponse for AppError { - fn into_response(self) -> Response { - // TODO: add a `dev` mode to `config.app`, and: - // * when enabled, respond with a stack trace - // * when disabled, respond with a generic error message that doesn't leak any details - (StatusCode::INTERNAL_SERVER_ERROR, format!("Error: {}", self.0)).into_response() - } -} -impl> From for AppError { - fn from(e: E) -> Self { - Self(e.into()) - } + Ok((r, state)) } diff --git a/src/app/posts.rs b/src/app/posts.rs new file mode 100644 index 00000000..5ea85716 --- /dev/null +++ b/src/app/posts.rs @@ -0,0 +1,323 @@ +use crate::db::list::List; +use crate::db::post::{Post, UpdatePost}; +use crate::prelude::*; + +/// Add all `post` routes to the router. +#[rustfmt::skip] +pub fn add_routes(router: AppRouter) -> AppRouter { + router + .public_routes(|r| { + r.route("/p/{slug}", get(read::view_page)) + }) + .restricted_routes(User::WRITER, |r| { + r.route("/posts", get(read::list_page)) + .route("/posts/new", get(edit::new_page)) + .route("/posts/{slug}/edit", get(edit::edit_page).post(edit::edit_form)) + .route("/posts/{slug}/delete", post(edit::delete_form)) + .route("/posts/{slug}/send", get(send::page).post(send::send_form)) + .route("/posts/{slug}/preview", get(read::preview_page)) + }) +} + +/// View and list posts. +mod read { + use super::*; + + // Display a list of posts. + pub async fn list_page(user: User, State(state): State) -> HtmlResult { + let posts = Post::list(&state.db).await?; + + #[derive(Template, WebTemplate)] + #[template(path = "posts/list.html")] + struct Html { + user: Option, + posts: Vec, + } + Ok(Html { user: Some(user), posts }.into_response()) + } + + // Display a single post. + pub async fn view_page( + user: Option, State(state): State, Path(slug): Path, + ) -> HtmlResult { + let Some(post) = Post::lookup_by_slug(&state.db, &slug).await? else { + bail_not_found!(); + }; + + #[derive(Template, WebTemplate)] + #[template(path = "posts/view.html")] + struct Html { + user: Option, + post: Post, + } + Ok(Html { user, post }.into_response()) + } + + // Display a preview of a post as it would appear in an email. + pub async fn preview_page(State(state): State, Path(slug): Path) -> HtmlResult { + let Some(post) = Post::lookup_by_slug(&state.db, &slug).await? else { + bail_not_found!(); + }; + + #[derive(Template, WebTemplate)] + #[template(path = "emails/post.html")] + struct EmailHtml { + email_id: i64, + post: Post, + post_url: String, + } + Ok(EmailHtml { + post_url: format!("{}/p/{}", &state.config.app.url, &post.slug), + email_id: 0, + post, + } + .into_response()) + } +} + +/// Create and edit posts. +mod edit { + use super::*; + use crate::utils::editor::{Editor, EditorContent}; + + #[derive(Template, WebTemplate)] + #[template(path = "posts/edit.html")] + struct EditHtml { + user: Option, + post: Post, + editor: Editor, + } + + // New post page. + pub async fn new_page(user: User) -> HtmlResult { + Ok(EditHtml { + user: Some(user), + post: Post { + id: 0, + title: "".into(), + slug: "".into(), + author: "".into(), + content: "".into(), + created_at: Utc::now().naive_utc(), + updated_at: Utc::now().naive_utc(), + }, + editor: Editor { + url: "/posts/{id}/edit", + snapshot_prefix: "post", + entity_id: None, + content: None, + }, + } + .into_response()) + } + + // Edit post page. + pub async fn edit_page( + user: User, State(state): State, Path(slug): Path, + ) -> HtmlResult { + let Some(post) = Post::lookup_by_slug(&state.db, &slug).await? else { + bail_not_found!() + }; + + Ok(EditHtml { + user: Some(user), + editor: Editor { + url: "/posts/{id}/edit", + snapshot_prefix: "post", + entity_id: Some(post.id), + content: Some(EditorContent { html: post.content.clone(), updated_at: post.updated_at }), + }, + post, + } + .into_response()) + } + + // Edit post form. + #[derive(serde::Deserialize)] + pub struct EditForm { + id: i64, + #[serde(flatten)] + post: UpdatePost, + } + #[derive(serde::Serialize)] + pub struct EditResponse { + id: Option, + updated_at: Option, + error: Option, + } + pub async fn edit_form( + State(state): State, Form(form): Form, + ) -> JsonResult { + let (id, updated_at) = match form.id { + 0 => { + if Post::lookup_by_slug(&state.db, &form.post.slug).await?.is_some() { + return Ok(Json(EditResponse { + id: None, + updated_at: None, + error: Some("A post with that slug already exists.".into()), + })); + } + Post::create(&state.db, &form.post).await? + } + id => Post::update(&state.db, id, &form.post).await?, + }; + + let updated_at = updated_at.and_utc().timestamp_millis(); + + Ok(Json(EditResponse { id: Some(id), updated_at: Some(updated_at), error: None })) + } + + // Delete post form. + pub async fn delete_form(State(state): State, Path(slug): Path) -> HtmlResult { + let Some(post) = Post::lookup_by_slug(&state.db, &slug).await? else { + bail_not_found!(); + }; + Post::delete(&state.db, post.id).await?; + Ok(Redirect::to("/posts").into_response()) + } +} + +mod send { + use axum::body::Body; + use futures::StreamExt; + + use super::*; + + /// Display the form to send a post. + pub async fn page( + user: User, State(state): State, Path(slug): Path, + ) -> HtmlResult { + let Some(post) = Post::lookup_by_slug(&state.db, &slug).await? else { + bail_not_found!(); + }; + + #[derive(sqlx::FromRow)] + struct ListExt { + id: i64, + name: String, + count: i64, + sent: i64, + } + let lists = sqlx::query_as!( + ListExt, + r#" + SELECT + l.id, + l.name, + COUNT(lm.user_id) AS count, + SUM( + CASE WHEN EXISTS ( + SELECT 1 + FROM emails e + WHERE e.user_id = u.id + AND e.list_id = l.id + AND e.post_id = ? + AND e.sent_at IS NOT NULL + ) + THEN 1 ELSE 0 END + ) AS sent + FROM lists l + LEFT JOIN list_members lm ON lm.list_id = l.id + LEFT JOIN users u ON u.id = lm.user_id + GROUP BY l.id; + "#, + post.id, + ) + .fetch_all(&state.db) + .await?; + + #[derive(Template, WebTemplate)] + #[template(path = "posts/send.html")] + struct Html { + user: Option, + post: Post, + lists: Vec, + ratelimit: usize, + } + let ratelimit = state.config.email.ratelimit; + Ok(Html { user: Some(user), post, lists, ratelimit }.into_response()) + } + + #[derive(Template, WebTemplate)] + #[template(path = "emails/post.html")] + struct EmailHtml { + email_id: i64, + post: Post, + post_url: String, + } + + // Process the form and create or edit a post. + #[derive(serde::Deserialize)] + pub struct SendForm { + list_id: i64, + resend: bool, + } + pub async fn send_form( + State(state): State, Path(slug): Path, Form(form): Form, + ) -> HtmlResult { + let Some(post) = Post::lookup_by_slug(&state.db, &slug).await? else { + bail_not_found!(); + }; + let Some(list) = List::lookup_by_id(&state.db, form.list_id).await? else { + bail_not_found!(); + }; + + let emails = match form.resend { + false => Email::create_send_posts(&state.db, post.id, list.id).await?, + true => Email::create_resend_posts(&state.db, post.id, list.id).await?, + }; + + let mut email_template = EmailHtml { + email_id: 0, + post: post.clone(), + post_url: format!("{}/p/{}", &state.config.app.url, &post.slug), + }; + let mut messages = vec![]; + let mut email_ids = vec![]; + for Email { id, address, sent_at, .. } in emails { + if sent_at.is_some() { + continue; + } + + email_template.email_id = id; + + let from = &state.config.email.from; + let reply_to = state.config.email.newsletter_reply_to.as_ref().unwrap_or(from); + let message = state + .mailer + .builder() + .to(address.parse().unwrap()) + .reply_to(reply_to.clone()) + .subject(&post.title) + .header(lettre::message::header::ContentType::TEXT_HTML) + .body(email_template.render()?) + .unwrap(); + + messages.push(message); + email_ids.push(id); + } + + let email_ids = futures::stream::iter(email_ids); + let results = state.mailer.send_batch(Arc::clone(&state), messages).await; + + let body = Body::from_stream(async_stream::stream! { + let mut stream = Box::pin(results.zip(email_ids)); + while let Some((progress, email_id)) = stream.next().await { + let json = match progress { + Ok(p) => { + Email::mark_sent(&state.db, email_id).await?; + json!({"sent": p.sent, "remaining": p.remaining}) + } + Err(e) => { + let e = e.message(); + Email::mark_error(&state.db, email_id, e).await?; + json!({"error": e}) + } + }.to_string(); + yield Ok::<_, AnyError>(format!("{json}\n")); + } + }); + + Ok(body.into_response()) + } +} diff --git a/src/app/webhooks.rs b/src/app/webhooks.rs new file mode 100644 index 00000000..b54db939 --- /dev/null +++ b/src/app/webhooks.rs @@ -0,0 +1,208 @@ +use crate::prelude::*; + +/// Add all webhook routes to the router. +pub fn add_routes(router: AppRouter) -> AppRouter { + router.public_routes(|r| r.route("/webhooks/stripe", post(stripe::webhook))) +} + +pub mod stripe { + use axum::http::HeaderMap; + use hmac::{Hmac, Mac}; + use sha2::Sha256; + + use super::*; + use crate::db::event::Event; + use crate::db::event_flyer::EventFlyer; + use crate::db::rsvp_session::RsvpSession; + + type HmacSha256 = Hmac; + + pub async fn webhook( + State(state): State, headers: HeaderMap, body: String, + ) -> JsonResult<()> { + let signature = headers + .get("stripe-signature") + .ok_or_else(invalid)? + .to_str() + .map_err(|_| invalid())?; + + // 1. Parse timestamp and signatures + let mut timestamp: Option<&str> = None; + let mut signatures: Vec<&str> = Vec::new(); + for part in signature.split(',') { + let mut kv = part.split('='); + if let (Some(k), Some(v)) = (kv.next(), kv.next()) { + match k { + "t" => timestamp = Some(v), + "v1" => signatures.push(v), + _ => {} + } + } + } + let timestamp = timestamp.ok_or_else(invalid)?; + if signatures.is_empty() { + crate::bail_invalid!(); + } + + // 2. Reconstruct `signed_payload` + let signed_payload = format!("{timestamp}.{body}"); + + // 3. Compute expected signature + let mut mac = HmacSha256::new_from_slice(state.config.stripe.webhook_key.as_bytes()).unwrap(); + mac.update(signed_payload.as_bytes()); + let expected_signature = hex::encode(mac.finalize().into_bytes()); + + // 4. Compare against provided signatures. We ignore the timestamp for now. + let valid = signatures.iter().any(|sig| sig == &expected_signature); + if !valid { + crate::bail_unauthorized!(); + } + + // tracing::debug!("STRIPE: {body}"); + + // 5. Dispatch to the correct handler + #[derive(serde::Deserialize)] + struct Type { + #[serde(rename = "type")] + ty: String, + } + #[derive(serde::Deserialize)] + struct Event { + data: EventData, + } + #[derive(serde::Deserialize)] + struct EventData { + object: T, + } + let event: Type = serde_json::from_str(&body).map_err(|_| invalid())?; + fn parse(body: &str) -> Result { + let event: Event = serde_json::from_str(body).map_err(|_| invalid())?; + Ok(event.data.object) + } + match event.ty.as_str() { + "checkout.session.completed" => { + checkout_session_completed(state, parse::(&body)?).await? + } + ty => tracing::debug!("Stripe: unhandled webhook of type={ty:?}"), + } + + Ok(Json(())) + } + + #[derive(Debug, serde::Deserialize)] + struct CheckoutSessionCompleted { + client_reference_id: String, + payment_intent: String, + payment_status: String, + } + async fn checkout_session_completed( + state: SharedAppState, payload: CheckoutSessionCompleted, + ) -> Result<()> { + // unwrap(): we assume Stripe won't send us bogus data. RsvpSessions are never deleted. + let session_id: i64 = payload.client_reference_id.parse().unwrap(); + let Some(session) = RsvpSession::lookup_by_id(&state.db, session_id).await? else { + bail!( + "Stripe: Unknown rsvp_session={session_id} while handling webhook for payment_intent={}", + payload.payment_intent, + ); + }; + let Some(user_id) = session.user_id else { + bail!( + "Stripe: Got rsvp_session={session_id} with empty user_id while handling webhook for payment_intent={}", + payload.payment_intent, + ); + }; + let Some(user) = User::lookup_by_id(&state.db, user_id).await? else { + bail!( + "Stripe: Got rsvp_session={session_id} with unknown user_id={} while handling webhook for payment_intent={}", + user_id, + payload.payment_intent, + ); + }; + let Some(event) = Event::lookup_by_id(&state.db, session.event_id).await? else { + bail!( + "Stripe: Got rsvp_session={session_id} with nonexistant event_id={} while handling webhook for payment_intent={}", + session.event_id, + payload.payment_intent, + ); + }; + + match payload.payment_status.as_str() { + "paid" => { + tracing::info!( + "Stripe[checkout.session.completed]: session={session:?} intent={:?}", + payload.payment_intent + ); + + session.set_status(&state.db, RsvpSession::PAYMENT_CONFIRMED).await?; + session.set_payment_intent_id(&state.db, &payload.payment_intent).await?; + + if !Email::have_sent_confirmation(&state.db, session.event_id, user_id).await? { + let email = Email::create_confirmation(&state.db, session.event_id, user_id).await?; + let flyer = EventFlyer::lookup(&state.db, event.id).await?; + + #[derive(Template, WebTemplate)] + #[template(path = "emails/event_confirmation.html")] + struct ConfirmationEmailHtml { + email_id: i64, + event: Event, + token: String, + flyer: Option, + } + + let from = &state.config.email.from; + let reply_to = state.config.email.contact_to.as_ref().unwrap_or(from); + let subject = event + .confirmation_subject + .clone() + .unwrap_or_else(|| format!("Confirmation for {}", event.title)); + let message = state + .mailer + .builder() + .to(user.email.parse().unwrap()) + .reply_to(reply_to.clone()) + .subject(subject) + .header(lettre::message::header::ContentType::TEXT_HTML) + .body( + ConfirmationEmailHtml { + email_id: email.id, + event: event.clone(), + token: session.token, + flyer, + } + .render()?, + ) + .unwrap(); + + match state.mailer.send(&message).await { + Ok(_) => { + Email::mark_sent(&state.db, email.id).await?; + tracing::info!( + "Confirmation for event_id={} sent to email={:?} from webhook", + event.id, + user.email + ); + } + Err(e) => { + let e = e.message(); + Email::mark_error(&state.db, email.id, e).await?; + let message = format!( + "Error sending confirmation for event_id={} to email={:?} from webhook: {e}", + event.id, user.email + ); + tracing::error!(message); + crate::utils::sentry::report(message); + } + }; + } + } + status => { + tracing::error!( + "Stripe[checkout.session.completed]: unknown payment_status={status} for rsvp_session={session_id}" + ) + } + } + + Ok(()) + } +} diff --git a/src/db/contact_us.rs b/src/db/contact_us.rs new file mode 100644 index 00000000..2513550a --- /dev/null +++ b/src/db/contact_us.rs @@ -0,0 +1,45 @@ +use crate::prelude::*; + +/// A "contact us" form submission. +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct ContactUs { + pub id: i64, + pub name: Option, + pub reply_to: Option, + + pub subject: String, + pub message: String, + + pub created_at: NaiveDateTime, +} + +pub struct CreateContactUs { + pub name: Option, + pub reply_to: Option, + + pub subject: String, + pub message: String, +} + +impl ContactUs { + pub async fn lookup(db: &Db, id: i64) -> Result> { + Ok(sqlx::query_as!(Self, "SELECT * FROM contact_us WHERE id = ?", id) + .fetch_optional(db) + .await?) + } + + /// Create a new session token for a user. + pub async fn create(db: &Db, form: &CreateContactUs) -> Result { + let row = sqlx::query!( + "INSERT INTO contact_us (name, reply_to, subject, message) VALUES (?, ?, ?, ?)", + form.name, + form.reply_to, + form.subject, + form.message + ) + .execute(db) + .await?; + + Ok(row.last_insert_rowid()) + } +} diff --git a/src/db/email.rs b/src/db/email.rs new file mode 100644 index 00000000..13e9f10c --- /dev/null +++ b/src/db/email.rs @@ -0,0 +1,397 @@ +use crate::prelude::*; + +/// A record of a an email which has been sent. +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct Email { + pub id: i64, + pub kind: String, + pub user_id: i64, + pub user_version: i64, + pub address: String, + + pub post_id: Option, + pub list_id: Option, + pub event_id: Option, + pub notification_id: Option, + + pub error: Option, + pub created_at: NaiveDateTime, + pub sent_at: Option, + pub opened_at: Option, +} + +impl Email { + /// A login email. + pub const LOGIN: &'static str = "login"; + /// An email containing a post. + pub const POST: &'static str = "post"; + + /// An event invitation email. + pub const EVENT_INVITE: &'static str = "event/invite"; + /// An event purchase confirmation email. + pub const EVENT_CONFIRMATION: &'static str = "event/confirmation"; + /// An event day-of info email. + pub const EVENT_DAYOF: &'static str = "event/dayof"; + + /// Lookup an email by id. + pub async fn lookup(db: &Db, id: i64) -> Result> { + let res = sqlx::query_as!( + Self, + r#"SELECT e.*, u.email as address FROM emails e + JOIN users u ON u.id = e.user_id + WHERE e.id = ? + "#, + id + ) + .fetch_optional(db) + .await?; + Ok(res) + } + + /// Create a new email record. + pub async fn create_login(db: &Db, user: &User) -> Result { + let res = sqlx::query!( + "INSERT INTO emails (kind, user_id, user_version) VALUES (?, ?, ?)", + Email::LOGIN, + user.id, + user.version + ) + .execute(db) + .await?; + Ok(res.last_insert_rowid()) + } + + /// Create email entries for sending the given post to all users on the given list. + /// Returns rows with `sent_at` set if the post was already emailed to a user. + pub async fn create_send_posts(db: &Db, post_id: i64, list_id: i64) -> Result> { + let existing = sqlx::query_as!( + Email, + r#" + SELECT e.*, u.email as address FROM emails e + JOIN users u ON u.id = e.user_id + WHERE e.post_id = ? AND e.list_id = ? + AND ifnull(e.sent_at, '') = ( + SELECT ifnull(MAX(ee.sent_at), '') + FROM emails ee + WHERE ee.user_id = e.user_id + AND ee.post_id = e.post_id + AND ee.list_id = e.list_id + ); + "#, + post_id, + list_id + ) + .fetch_all(db) + .await?; + + let new = sqlx::query_as!( + Email, + r#" + INSERT INTO emails (kind, user_id, user_version, post_id, list_id) + SELECT ?, u.id, uh.version, ?, lm.list_id + FROM list_members lm + JOIN users u ON u.id = lm.user_id + JOIN user_history uh ON uh.user_id = u.id + WHERE lm.list_id = ? + AND NOT EXISTS ( + SELECT 1 + FROM emails ee + WHERE ee.user_id = u.id + AND ee.post_id = ? + AND ee.list_id = lm.list_id + ) + RETURNING *, ( + SELECT u.email FROM users u + WHERE u.id = emails.user_id + ) AS address + "#, + Email::POST, + post_id, + list_id, + post_id, + ) + .fetch_all(db) + .await?; + + let mut all = existing; + all.extend(new); + Ok(all) + } + + /// Create email entries for sending the given post to all users on the given list. + /// Returns rows with `sent_at` set if the post was already emailed to a user. + pub async fn create_send_invites(db: &Db, event_id: i64, list_id: i64) -> Result> { + let existing = sqlx::query_as!( + Email, + r#" + SELECT e.*, u.email as address FROM emails e + JOIN users u ON u.id = e.user_id + WHERE e.kind = ? AND e.event_id = ? AND e.list_id = ? + AND ifnull(e.sent_at, '') = ( + SELECT ifnull(MAX(ee.sent_at), '') + FROM emails ee + WHERE ee.kind = e.kind + AND ee.list_id = e.list_id + AND ee.user_id = e.user_id + AND ee.event_id = e.event_id + ); + "#, + Email::EVENT_INVITE, + event_id, + list_id + ) + .fetch_all(db) + .await?; + + let new = sqlx::query_as!( + Email, + r#" + INSERT INTO emails (kind, user_id, user_version, event_id, list_id) + SELECT ?, u.id, uh.version, ?, ? + FROM list_members lm + JOIN users u ON u.id = lm.user_id + JOIN user_history uh ON uh.user_id = u.id + WHERE lm.list_id = ? + AND NOT EXISTS ( + SELECT 1 + FROM emails ee + WHERE ee.kind = ? + AND ee.user_id = u.id + AND ee.event_id = ? + AND ee.list_id = ? + ) + RETURNING *, ( + SELECT u.email FROM users u + WHERE u.id = emails.user_id + ) AS address + "#, + Email::EVENT_INVITE, + event_id, + list_id, + list_id, + Email::EVENT_INVITE, + event_id, + list_id, + ) + .fetch_all(db) + .await?; + + let mut all = existing; + all.extend(new); + Ok(all) + } + + pub async fn have_sent_confirmation(db: &Db, event_id: i64, user_id: i64) -> Result { + let row = sqlx::query!( + "SELECT id FROM emails WHERE kind = ? AND event_id = ? AND user_id = ?", + Email::EVENT_CONFIRMATION, + event_id, + user_id + ) + .fetch_optional(db) + .await?; + Ok(row.is_some()) + } + + pub async fn create_confirmation(db: &Db, event_id: i64, user_id: i64) -> Result { + let row = sqlx::query_as!( + Email, + r#" + INSERT INTO emails (kind, user_id, user_version, event_id) + SELECT ?, u.id, uh.version, ? + FROM users u + JOIN user_history uh ON uh.user_id = u.id + WHERE u.id = ? + RETURNING *, ( + SELECT u.email FROM users u + WHERE u.id = emails.user_id + ) AS "address!" + "#, + Email::EVENT_CONFIRMATION, + event_id, + user_id, + ) + .fetch_one(db) + .await?; + Ok(row) + } + + pub async fn create_send_dayof_single(db: &Db, event_id: i64, user_id: i64) -> Result { + let row = sqlx::query_as!( + Email, + r#" + INSERT INTO emails (kind, user_id, user_version, event_id) + SELECT ?, u.id, uh.version, ? + FROM users u + JOIN user_history uh ON uh.user_id = u.id + WHERE u.id = ? + RETURNING *, ( + SELECT u.email FROM users u + WHERE u.id = emails.user_id + ) AS "address!" + "#, + Email::EVENT_DAYOF, + event_id, + user_id, + ) + .fetch_one(db) + .await?; + Ok(row) + } + + /// Create email entries for sending the given post to all users on the given list. + /// Returns rows with `sent_at` set if the post was already emailed to a user. + pub async fn create_send_dayof_batch(db: &Db, event_id: i64) -> Result> { + let existing = sqlx::query_as!( + Email, + r#" + SELECT e.*, u.email as address FROM emails e + JOIN users u ON u.id = e.user_id + WHERE e.kind = ? AND e.event_id = ? + AND ifnull(e.sent_at, '') = ( + SELECT ifnull(MAX(ee.sent_at), '') + FROM emails ee + WHERE ee.kind = e.kind + AND ee.user_id = e.user_id + AND ee.event_id = e.event_id + ); + "#, + Email::EVENT_DAYOF, + event_id, + ) + .fetch_all(db) + .await?; + + let new = sqlx::query_as!( + Email, + r#" + INSERT INTO emails (kind, user_id, user_version, event_id) + SELECT ?, u.id, uh.version, ? + FROM rsvps r + JOIN rsvp_sessions rs ON rs.id = r.session_id + JOIN users u ON u.id = r.user_id + JOIN user_history uh ON uh.user_id = u.id + WHERE rs.event_id = ? + AND NOT EXISTS ( + SELECT 1 + FROM emails ee + WHERE ee.kind = ? + AND ee.user_id = u.id + AND ee.event_id = ? + ) + RETURNING *, ( + SELECT u.email FROM users u + WHERE u.id = emails.user_id + ) AS "address!" + "#, + Email::EVENT_INVITE, + event_id, + event_id, + Email::EVENT_INVITE, + event_id, + ) + .fetch_all(db) + .await?; + + let mut all = existing; + all.extend(new); + Ok(all) + } + + /// Create email entries for resending the given post to all users on the given list. + pub async fn create_resend_posts(db: &Db, post_id: i64, list_id: i64) -> Result> { + let existing_unsent = sqlx::query_as!( + Email, + r#" + SELECT e.*, u.email as address + FROM emails e + JOIN users u ON u.id = e.user_id + WHERE e.post_id = ? AND e.list_id = ? + AND e.sent_at IS NULL + AND e.id = ( + SELECT MAX(ee.id) + FROM emails ee + WHERE ee.user_id = e.user_id + AND ee.post_id = e.post_id + AND ee.list_id = e.list_id + AND ee.sent_at IS NULL + ); + "#, + post_id, + list_id, + ) + .fetch_all(db) + .await?; + + let new = sqlx::query_as!( + Email, + r#" + INSERT INTO emails (kind, user_id, user_version, post_id, list_id) + SELECT ?, u.id, uh.version, ?, lm.list_id + FROM list_members lm + JOIN users u ON u.id = lm.user_id + JOIN user_history uh ON uh.user_id = u.id + WHERE lm.list_id = ? + AND NOT EXISTS ( + SELECT 1 + FROM emails e + WHERE e.user_id = u.id + AND e.post_id = ? + AND e.list_id = lm.list_id + AND e.sent_at IS NULL + ) + RETURNING *, ( + SELECT u.email FROM users u + WHERE u.id = emails.user_id + ) as address + "#, + Email::POST, + post_id, + list_id, + post_id, + ) + .fetch_all(db) + .await?; + + let mut all = existing_unsent; + all.extend(new); + Ok(all) + } + + /// Mark an email as sent. + pub async fn mark_sent(db: &Db, id: i64) -> Result<()> { + sqlx::query!( + r#"UPDATE emails SET sent_at = CURRENT_TIMESTAMP + WHERE id = ?"#, + id + ) + .execute(db) + .await?; + Ok(()) + } + + /// Mark an email as sent. + pub async fn mark_error(db: &Db, id: i64, error: &str) -> Result<()> { + sqlx::query!( + r#"UPDATE emails SET sent_at = CURRENT_TIMESTAMP, error = ? + WHERE id = ?"#, + error, + id + ) + .execute(db) + .await?; + Ok(()) + } + + /// Mark an email as opened. + pub async fn mark_opened(db: &Db, id: i64) -> Result<()> { + sqlx::query!( + r#"UPDATE emails SET opened_at = CURRENT_TIMESTAMP + WHERE id = ? AND opened_at IS NULL"#, + id + ) + .execute(db) + .await?; + Ok(()) + } +} diff --git a/src/db/email_queue.rs b/src/db/email_queue.rs new file mode 100644 index 00000000..90d646e2 --- /dev/null +++ b/src/db/email_queue.rs @@ -0,0 +1,108 @@ +use crate::prelude::*; + +/// A batch of emails to be enqueued. +#[derive(Debug, Clone, sqlx::FromRow, serde::Serialize)] +pub struct EmailBatch { + pub id: i64, + pub size: i64, + pub sent: i64, + pub errored: i64, + + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, +} + +impl EmailBatch { + pub async fn create_single(db: &Db) -> Result { + Self::create(db, 1).await + } + pub async fn create(db: &Db, size: usize) -> Result { + let size = size as i64; + let batch = sqlx::query_as!( + Self, + r#"INSERT INTO email_batches (size, sent, errored) + VALUES (?, 0, 0) + RETURNING *"#, + size, + ) + .fetch_one(db) + .await?; + Ok(batch) + } + + pub async fn enqueue_front(&self, db: &Db) -> Result<()> { + sqlx::query!( + "INSERT INTO email_queue (batch_id, position) + SELECT ?, COALESCE(MIN(position) - 1, 0) FROM email_queue", + self.id, + ) + .execute(db) + .await?; + Ok(()) + } + pub async fn enqueue_back(&self, db: &Db) -> Result<()> { + sqlx::query!( + "INSERT INTO email_queue (batch_id, position) + SELECT ?, COALESCE(MAX(position) + 1, 0) FROM email_queue", + self.id, + ) + .execute(db) + .await?; + Ok(()) + } + pub async fn dequeue(&self, db: &Db) -> Result<()> { + sqlx::query!("DELETE FROM email_queue WHERE batch_id = ?", self.id) + .execute(db) + .await?; + Ok(()) + } + + pub async fn next(db: &Db) -> Result> { + let rows = sqlx::query_as!( + Email, + r#" + SELECT e.*, u.email as address + FROM email_queue q + JOIN email_batches b ON b.id = q.batch_id + JOIN emails e ON e.batch_id = b.id + JOIN users u ON u.id = e.user_id + WHERE e.sent_at IS NULL AND e.errored_at IS NULL + ORDER BY q.position, e.id + LIMIT 1 + "#, + ) + .fetch_optional(db) + .await?; + + Ok(rows) + } + + pub async fn inc_sent(db: &Db, batch_id: i64) -> Result { + Self::inc(db, batch_id, 1, 0).await + } + pub async fn inc_errored(db: &Db, batch_id: i64) -> Result { + Self::inc(db, batch_id, 0, 1).await + } + async fn inc(db: &Db, batch_id: i64, bump_sent: usize, bump_errored: usize) -> Result { + let sent_inc = bump_sent as i64; + let errored_inc = bump_errored as i64; + let batch = sqlx::query_as!( + Self, + "UPDATE email_batches + SET sent = sent + ?, errored = errored + ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + RETURNING *", + sent_inc, + errored_inc, + batch_id + ) + .fetch_one(db) + .await?; + + if batch.sent + batch.errored == batch.size { + batch.dequeue(db).await?; + } + + Ok(batch) + } +} diff --git a/src/db/event.rs b/src/db/event.rs new file mode 100644 index 00000000..d9408db9 --- /dev/null +++ b/src/db/event.rs @@ -0,0 +1,436 @@ +use image::DynamicImage; + +use crate::db::event_flyer::EventFlyer; +use crate::db::rsvp::EventRsvp; +use crate::db::spot::Spot; +use crate::prelude::*; + +#[derive(Clone, Debug, sqlx::FromRow, serde::Serialize)] +pub struct Event { + pub id: i64, + pub title: String, + pub slug: String, + pub start: NaiveDateTime, + pub end: Option, + pub capacity: i64, + pub unlisted: bool, + pub closed: bool, + pub guest_list_id: Option, + pub spots_per_person: Option, + + pub description_html: Option, + pub description_updated_at: Option, + + pub invite_subject: Option, + pub invite_html: Option, + pub invite_updated_at: Option, + pub invite_sent_at: Option, + + pub confirmation_subject: Option, + pub confirmation_html: Option, + pub confirmation_updated_at: Option, + + pub dayof_subject: Option, + pub dayof_html: Option, + pub dayof_updated_at: Option, + pub dayof_sent_at: Option, + + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, +} + +#[derive(Debug, serde::Deserialize)] +pub struct UpdateEvent { + pub title: String, + pub slug: String, + + pub start: NaiveDateTime, + pub end: Option, + + pub capacity: i64, + pub unlisted: bool, + pub closed: bool, + pub guest_list_id: Option, + pub spots_per_person: Option, +} + +/// Event with RSVP count for the admin list page. +#[derive(Clone, Debug, sqlx::FromRow, serde::Serialize)] +pub struct EventWithStats { + pub id: i64, + pub title: String, + pub slug: String, + pub start: NaiveDateTime, + pub guest_list_id: Option, + pub capacity: i64, + pub rsvp_count: i64, + pub total_contributions: i64, +} + +impl Event { + pub async fn list(db: &Db) -> Result> { + let events = sqlx::query_as!( + EventWithStats, + r#"SELECT + e.id, e.title, e.slug, e.start, e.guest_list_id, e.capacity, + COALESCE( + (SELECT COUNT(*) + FROM rsvps r + JOIN rsvp_sessions rs ON rs.id = r.session_id + WHERE rs.event_id = e.id + AND rs.status IN ('payment_pending', 'payment_confirmed')), + 0 + ) as "rsvp_count!: i64", + COALESCE( + (SELECT SUM(r.contribution) + FROM rsvps r + JOIN rsvp_sessions rs ON rs.id = r.session_id + WHERE rs.event_id = e.id + AND rs.status IN ('payment_pending', 'payment_confirmed')), + 0 + ) as "total_contributions!: i64" + FROM events e"# + ) + .fetch_all(db) + .await?; + Ok(events) + } + + pub async fn list_upcoming(db: &Db) -> Result> { + let events = sqlx::query_as!( + Self, + r#"SELECT * FROM events + WHERE start > DATETIME(CURRENT_TIMESTAMP, '-24 hours') + AND unlisted = FALSE + ORDER BY start ASC"# + ) + .fetch_all(db) + .await?; + Ok(events) + } + + pub async fn list_past(db: &Db) -> Result> { + let events = sqlx::query_as!( + Self, + r#"SELECT * FROM events + WHERE start <= DATETIME(CURRENT_TIMESTAMP, '-24 hours') + AND unlisted = FALSE + ORDER BY start DESC"# + ) + .fetch_all(db) + .await?; + Ok(events) + } + + // Create a new event. + pub async fn create(db: &Db, event: &UpdateEvent, flyer: &Option) -> Result { + let event_id = sqlx::query!( + r#"INSERT INTO events + (title, slug, start, end, capacity, unlisted, closed, guest_list_id, spots_per_person) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"#, + event.title, + event.slug, + event.start, + event.end, + event.capacity, + event.unlisted, + event.closed, + event.guest_list_id, + event.spots_per_person, + ) + .execute(db) + .await? + .last_insert_rowid(); + + if let Some(image) = flyer { + EventFlyer::create_or_update(db, event_id, image).await?; + } + + Ok(event_id) + } + + // Update an event. + pub async fn update(db: &Db, id: i64, event: &UpdateEvent, flyer: &Option) -> Result<()> { + sqlx::query!( + r#"UPDATE events + SET title = ?, + slug = ?, + start = ?, + end = ?, + capacity = ?, + unlisted = ?, + closed = ?, + guest_list_id = ?, + spots_per_person = ? + WHERE id = ?"#, + event.title, + event.slug, + event.start, + event.end, + event.capacity, + event.unlisted, + event.closed, + event.guest_list_id, + event.spots_per_person, + id + ) + .execute(db) + .await?; + + if let Some(image) = flyer { + EventFlyer::create_or_update(db, id, image).await?; + } + + Ok(()) + } + + pub async fn update_invite(db: &Db, id: i64, subject: String, html: String) -> Result { + let row = sqlx::query!( + "UPDATE EVENTS + SET invite_subject = ?, + invite_html = ?, + invite_updated_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + RETURNING invite_updated_at", + subject, + html, + id, + ) + .fetch_one(db) + .await?; + Ok(row.invite_updated_at.unwrap()) + } + + pub async fn mark_sent_invites(&self, db: &Db) -> Result<()> { + sqlx::query!("UPDATE events SET invite_sent_at = CURRENT_TIMESTAMP WHERE id = ?", self.id) + .execute(db) + .await?; + Ok(()) + } + + pub async fn update_confirmation( + db: &Db, id: i64, subject: String, html: String, + ) -> Result { + let row = sqlx::query!( + "UPDATE EVENTS + SET confirmation_subject = ?, + confirmation_html = ?, + confirmation_updated_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + RETURNING confirmation_updated_at", + subject, + html, + id, + ) + .fetch_one(db) + .await?; + Ok(row.confirmation_updated_at.unwrap()) + } + + pub async fn update_dayof(db: &Db, id: i64, subject: String, html: String) -> Result { + let row = sqlx::query!( + "UPDATE EVENTS + SET dayof_subject = ?, + dayof_html = ?, + dayof_updated_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + RETURNING dayof_updated_at", + subject, + html, + id, + ) + .fetch_one(db) + .await?; + Ok(row.dayof_updated_at.unwrap()) + } + + pub async fn update_description(db: &Db, id: i64, html: String) -> Result { + let row = sqlx::query!( + "UPDATE EVENTS + SET description_html = ?, + description_updated_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + RETURNING description_updated_at", + html, + id, + ) + .fetch_one(db) + .await?; + Ok(row.description_updated_at.unwrap()) + } + + pub async fn mark_sent_dayof(&self, db: &Db) -> Result<()> { + sqlx::query!("UPDATE events SET dayof_sent_at = CURRENT_TIMESTAMP WHERE id = ?", self.id) + .execute(db) + .await?; + Ok(()) + } + + /// Delete an event and all related records (cascade delete). + /// Deletes: rsvps, rsvp_sessions, manual_rsvps, event_spots, event_flyers, then the event itself. + /// Note: emails are NOT deleted (kept for history). + pub async fn delete(db: &Db, id: i64) -> Result<()> { + // Delete RSVPs for this event (via sessions) + sqlx::query!( + "DELETE FROM rsvps WHERE session_id IN (SELECT id FROM rsvp_sessions WHERE event_id = ?)", + id + ) + .execute(db) + .await?; + // Delete RSVP sessions for this event + sqlx::query!("DELETE FROM rsvp_sessions WHERE event_id = ?", id) + .execute(db) + .await?; + // Delete manual RSVPs for this event + sqlx::query!("DELETE FROM manual_rsvps WHERE event_id = ?", id) + .execute(db) + .await?; + // Delete event-spot associations + sqlx::query!("DELETE FROM event_spots WHERE event_id = ?", id) + .execute(db) + .await?; + // Delete event flyer + sqlx::query!("DELETE FROM event_flyers WHERE event_id = ?", id) + .execute(db) + .await?; + // Finally delete the event itself + sqlx::query!("DELETE FROM events WHERE id = ?", id).execute(db).await?; + Ok(()) + } + + /// Lookup a post by id. + pub async fn lookup_by_id(db: &Db, id: i64) -> Result> { + let row = sqlx::query_as!(Self, "SELECT * FROM events WHERE id = ?", id) + .fetch_optional(db) + .await?; + Ok(row) + } + + /// Lookup a post by URL, if one exists. + pub async fn lookup_by_slug(db: &Db, slug: &str) -> Result> { + let row = sqlx::query_as!(Self, "SELECT * FROM events WHERE slug = ?", slug) + .fetch_optional(db) + .await?; + Ok(row) + } + + #[allow(unused)] + pub fn is_upcoming(&self, now: NaiveDateTime) -> bool { + // TODO: use with: + // let now = Utc::now().naive_utc(); + // let past = query.past.unwrap_or(false); + + now <= self.start || self.end.is_some_and(|end| now <= end) + } +} + +pub struct EventLimits { + pub total_limit: i64, + pub spot_limits: HashMap, +} + +impl Event { + /// Duplicate an event, including spots and flyer. + /// Returns the ID and slug of the new event. + pub async fn duplicate(db: &Db, event_id: i64) -> Result<(i64, String)> { + let event = Event::lookup_by_id(db, event_id) + .await? + .ok_or_else(|| any!("Event not found"))?; + + // Generate unique slug by appending an incrementing suffix + let mut suffix = 1; + let new_slug = loop { + let new_slug = format!("{}-{}", event.slug, suffix); + if Event::lookup_by_slug(db, &new_slug).await?.is_none() { + break new_slug; + } + suffix += 1; + }; + + let new_title = format!("{} (copy)", event.title); + + // Create the new event + let new_event_id = sqlx::query!( + r#"INSERT INTO events + (title, slug, start, end, capacity, unlisted, closed, guest_list_id, spots_per_person, + description_html, description_updated_at, + invite_subject, invite_html, invite_updated_at, + confirmation_subject, confirmation_html, confirmation_updated_at, + dayof_subject, dayof_html, dayof_updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, + ?, ?, ?, + ?, ?, ?, + ?, ?, ?)"#, + new_title, + new_slug, + event.start, + event.end, + event.capacity, + event.unlisted, + event.closed, + event.guest_list_id, + event.spots_per_person, + event.description_html, + event.description_updated_at, + event.invite_subject, + event.invite_html, + event.invite_updated_at, + event.confirmation_subject, + event.confirmation_html, + event.confirmation_updated_at, + event.dayof_subject, + event.dayof_html, + event.dayof_updated_at, + ) + .execute(db) + .await? + .last_insert_rowid(); + + // Duplicate spots (create new spot records and link to new event) + Spot::duplicate_for_event(db, event_id, new_event_id).await?; + + // Duplicate flyer if exists + EventFlyer::duplicate(db, event_id, new_event_id).await?; + + Ok((new_event_id, new_slug)) + } +} + +impl Event { + /// Calculate number of spots available of each type for this event. + pub fn compute_limits(&self, user: &Option, spots: &[Spot], rsvps: &[EventRsvp]) -> EventLimits { + // Overall event limits + let capacity_limit = self.capacity - rsvps.len() as i64; + let per_person_limit = self.spots_per_person.unwrap_or(i64::MAX); + let this_user_limit = user.as_ref().map(|_| i64::MAX).unwrap_or(i64::MAX); // TODO + let limit = capacity_limit.min(per_person_limit).min(this_user_limit); + + // Count rsvps per spot + let mut spot_num_rsvps: HashMap = Default::default(); + for rsvp in rsvps { + *spot_num_rsvps.entry(rsvp.spot_id).or_default() += 1; + } + + // Per-spot limits + let mut sum_spot_limits = 0; + let mut spot_limits = HashMap::default(); + for spot in spots { + let spot_total_limit = spot.qty_total - spot_num_rsvps.get(&spot.id).unwrap_or(&0); + let spot_per_person_limit = spot.qty_per_person; + let spot_limit = spot_total_limit.min(spot_per_person_limit); + + sum_spot_limits += spot_limit; + spot_limits.insert(spot.id, spot_limit); + } + + // Final limit is no more than the sum of all per-spot limits + let limit = limit.min(sum_spot_limits); + + EventLimits { total_limit: limit, spot_limits } + } +} diff --git a/src/db/event_flyer.rs b/src/db/event_flyer.rs new file mode 100644 index 00000000..71f8b668 --- /dev/null +++ b/src/db/event_flyer.rs @@ -0,0 +1,114 @@ +use base64::Engine; +use base64::prelude::BASE64_STANDARD as BASE64; +use image::DynamicImage; +use sqlx::Row; + +use crate::prelude::*; + +#[derive(Debug, Clone, Copy)] +pub enum EventFlyerSize { + Small, + Medium, + Large, + Full, +} + +pub struct EventFlyer { + pub width: i64, + pub height: i64, + pub thumb_base64: String, + pub version: i64, +} + +impl EventFlyer { + pub const CONTENT_TYPE: &'static str = "image/jpeg"; + + pub async fn create_or_update(db: &Db, event_id: i64, image: &DynamicImage) -> Result<()> { + let width = image.width(); + let height = image.height(); + + let image_full = crate::utils::image::encode_jpeg(image, None).await; + let image_lg = crate::utils::image::encode_jpeg(image, Some(1200)).await; + let image_md = crate::utils::image::encode_jpeg(image, Some(600)).await; + let image_sm = crate::utils::image::encode_jpeg(image, Some(300)).await; + let image_thumb = crate::utils::image::encode_jpeg(image, Some(60)).await; + + sqlx::query!( + r#"INSERT INTO event_flyers (event_id, width, height, image_full, image_lg, image_md, image_sm, image_thumb, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(event_id) DO UPDATE SET + width = excluded.width, + height = excluded.height, + image_full = excluded.image_full, + image_lg = excluded.image_lg, + image_md = excluded.image_md, + image_sm = excluded.image_sm, + image_thumb = excluded.image_thumb, + updated_at = CURRENT_TIMESTAMP"#, + event_id, + width, + height, + image_full, + image_lg, + image_md, + image_sm, + image_thumb + ) + .execute(db) + .await?; + Ok(()) + } + + pub async fn lookup(db: &Db, event_id: i64) -> Result> { + let row = sqlx::query!( + r#"SELECT width, height, image_thumb, strftime('%s', updated_at) as "version!: i64" + FROM event_flyers WHERE event_id = ?"#, + event_id + ) + .fetch_optional(db) + .await?; + + Ok(row.map(|r| EventFlyer { + width: r.width, + height: r.height, + thumb_base64: format!("data:image/jpeg;base64,{}", BASE64.encode(&r.image_thumb)), + version: r.version, + })) + } + + pub async fn serve(db: &Db, event_id: i64, size: EventFlyerSize) -> Result>> { + let column = match size { + EventFlyerSize::Small => "image_sm", + EventFlyerSize::Medium => "image_md", + EventFlyerSize::Large => "image_lg", + EventFlyerSize::Full => "image_full", + }; + + let query = format!("SELECT {column} FROM event_flyers WHERE event_id = ?"); + let flyer = sqlx::query(&query).bind(event_id).fetch_optional(db).await?; + + Ok(flyer.and_then(|row| row.try_get::, _>(0).ok())) + } + + pub async fn exists_for_event(db: &Db, event_id: i64) -> Result { + let result = sqlx::query!("SELECT COUNT(*) as count FROM event_flyers WHERE event_id = ?", event_id) + .fetch_one(db) + .await?; + Ok(result.count > 0) + } + + /// Duplicate a flyer from one event to another. + /// Does nothing if the source event has no flyer. + pub async fn duplicate(db: &Db, source_event_id: i64, target_event_id: i64) -> Result<()> { + sqlx::query!( + r#"INSERT INTO event_flyers (event_id, width, height, image_full, image_lg, image_md, image_sm, image_thumb, updated_at) + SELECT ?, width, height, image_full, image_lg, image_md, image_sm, image_thumb, CURRENT_TIMESTAMP + FROM event_flyers WHERE event_id = ?"#, + target_event_id, + source_event_id + ) + .execute(db) + .await?; + Ok(()) + } +} diff --git a/src/db/list.rs b/src/db/list.rs new file mode 100644 index 00000000..b970e2ac --- /dev/null +++ b/src/db/list.rs @@ -0,0 +1,168 @@ +use crate::db::user::CreateUser; +use crate::prelude::*; + +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct List { + pub id: i64, + pub name: String, + pub description: String, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, +} + +#[derive(serde::Deserialize)] +pub struct UpdateList { + pub id: Option, + pub name: String, + pub description: String, + pub emails: String, +} + +#[derive(serde::Serialize)] +pub struct ListWithCount { + pub id: i64, + pub name: String, + pub description: String, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + pub count: i64, +} + +impl List { + /// List all lists. + pub async fn list(db: &Db) -> Result> { + let lists = sqlx::query_as!(Self, "SELECT * FROM lists").fetch_all(db).await?; + Ok(lists) + } + + /// List all lists, and count the number of members in each list via list_members join. + pub async fn list_with_counts(db: &Db) -> Result> { + let lists = sqlx::query_as!( + ListWithCount, + "SELECT l.*, COUNT(m.list_id) AS count + FROM lists l + LEFT JOIN list_members m ON l.id = m.list_id + GROUP BY l.id" + ) + .fetch_all(db) + .await?; + Ok(lists) + } + + /// Create a list. + pub async fn create(db: &Db, event: &UpdateList) -> Result { + let res = sqlx::query!( + r#"INSERT INTO lists + (name, description) + VALUES (?, ?)"#, + event.name, + event.description + ) + .execute(db) + .await?; + Ok(res.last_insert_rowid()) + } + + /// Update a list. + pub async fn update(db: &Db, id: i64, event: &UpdateList) -> Result<()> { + sqlx::query!( + r#"UPDATE lists + SET name = ?, description = ? + WHERE id = ?"#, + event.name, + event.description, + id + ) + .execute(db) + .await?; + Ok(()) + } + + /// Lookup a list by id, if one exists. + pub async fn lookup_by_id(db: &Db, id: i64) -> Result> { + let list = sqlx::query_as!( + Self, + r#"SELECT * + FROM lists + WHERE id = ?"#, + id + ) + .fetch_optional(db) + .await?; + Ok(list) + } + + /// Lookup the members of a list. + pub async fn list_members(db: &Db, list_id: i64) -> Result> { + User::lookup_by_list_id(db, list_id).await + } + + pub async fn has_user_id(db: &Db, id: i64, user_id: i64) -> Result { + let exists = sqlx::query_scalar!( + r#" + SELECT EXISTS( + SELECT 1 + FROM list_members + WHERE list_id = ? AND user_id = ? + ) AS "exists!: bool" + "#, + id, + user_id, + ) + .fetch_one(db) + .await?; + Ok(exists) + } + + pub async fn has_email(db: &Db, id: i64, email: &str) -> Result { + let exists = sqlx::query_scalar!( + r#" + SELECT EXISTS ( + SELECT 1 + FROM list_members lm + LEFT JOIN users u ON u.id = lm.user_id + WHERE lm.list_id = ? + AND u.email = ? COLLATE NOCASE + ) AS "exists!: bool" + "#, + id, + email, + ) + .fetch_one(db) + .await?; + Ok(exists) + } + + /// Add members to a guest list. + pub async fn add_members(db: &Db, list_id: i64, emails: &[&str]) -> Result<()> { + // We could technically optimize this, but the common case is 1 signup. + for email in emails { + let user = User::get_or_create( + db, + &CreateUser { email: email.to_string(), first_name: None, last_name: None, phone: None }, + ) + .await?; + + sqlx::query!( + "INSERT OR IGNORE INTO list_members (list_id, user_id) VALUES (?, ?)", + list_id, + user.id, + ) + .execute(db) + .await?; + } + Ok(()) + } + + pub async fn remove_member(db: &Db, list_id: i64, user_id: i64) -> Result<()> { + sqlx::query!( + r#"DELETE FROM list_members + WHERE list_id = ? AND user_id = ?"#, + list_id, + user_id + ) + .execute(db) + .await?; + Ok(()) + } +} diff --git a/src/db/manual_rsvp.rs b/src/db/manual_rsvp.rs new file mode 100644 index 00000000..0af76307 --- /dev/null +++ b/src/db/manual_rsvp.rs @@ -0,0 +1,65 @@ +use crate::prelude::*; + +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct ManualRsvp { + pub event_id: i64, + pub user_id: i64, + pub creator_user_id: i64, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + pub checkin_at: Option, +} + +impl ManualRsvp { + pub async fn create(db: &Db, event_id: i64, user_id: i64, creator_user_id: i64) -> Result<()> { + sqlx::query!( + "INSERT INTO manual_rsvps (event_id, user_id, creator_user_id) VALUES (?, ?, ?)", + event_id, + user_id, + creator_user_id, + ) + .execute(db) + .await?; + Ok(()) + } + + pub async fn delete(db: &Db, event_id: i64, user_id: i64) -> Result<()> { + sqlx::query!("DELETE FROM manual_rsvps WHERE event_id = ? AND user_id = ?", event_id, user_id,) + .execute(db) + .await?; + Ok(()) + } + + pub async fn exists(db: &Db, event_id: i64, user_id: i64) -> Result { + let row = sqlx::query!( + "SELECT event_id FROM manual_rsvps WHERE event_id = ? AND user_id = ?", + event_id, + user_id, + ) + .fetch_optional(db) + .await?; + Ok(row.is_some()) + } + + pub async fn set_checkin_at(db: &Db, event_id: i64, user_id: i64) -> Result { + let row = sqlx::query!( + "UPDATE manual_rsvps SET checkin_at = CURRENT_TIMESTAMP WHERE event_id = ? AND user_id = ? RETURNING checkin_at AS 'checkin_at!'", + event_id, + user_id, + ) + .fetch_one(db) + .await?; + Ok(row.checkin_at) + } + + pub async fn clear_checkin_at(db: &Db, event_id: i64, user_id: i64) -> Result<()> { + sqlx::query!( + "UPDATE manual_rsvps SET checkin_at = NULL WHERE event_id = ? AND user_id = ?", + event_id, + user_id, + ) + .execute(db) + .await?; + Ok(()) + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs new file mode 100644 index 00000000..a7e0ee02 --- /dev/null +++ b/src/db/mod.rs @@ -0,0 +1,38 @@ +use sqlx::migrate::MigrateDatabase; +use sqlx::{Sqlite, SqlitePool}; + +use crate::prelude::*; +use crate::utils::config::DbConfig; + +pub type Db = SqlitePool; + +pub mod email; +pub mod event; +pub mod event_flyer; +pub mod list; +pub mod manual_rsvp; +pub mod notification; +pub mod post; +pub mod rsvp; +pub mod rsvp_session; +pub mod spot; +pub mod token; +pub mod user; + +/// Create a new db connection pool, initializing and running migrations if necessary. +pub async fn init(db_config: &DbConfig) -> Result { + let url = format!("sqlite://{}", db_config.file.display()); + if !Sqlite::database_exists(&url).await? { + Sqlite::create_database(&url).await?; + } + let db = SqlitePool::connect(&url).await?; + + sqlx::migrate!("./migrations").run(&db).await?; + + if let Some(seed_data) = &db_config.seed_data { + let sql = tokio::fs::read_to_string(seed_data).await?; + sqlx::raw_sql(&sql).execute(&db).await?; + } + + Ok(db) +} diff --git a/src/db/notification.rs b/src/db/notification.rs new file mode 100644 index 00000000..f068ee5c --- /dev/null +++ b/src/db/notification.rs @@ -0,0 +1,66 @@ +#![allow(unused)] + +use crate::prelude::*; + +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct Notification { + pub id: i64, + + pub name: String, + pub content: String, + + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, +} + +#[derive(serde::Deserialize)] +pub struct UpdateNotification { + pub event_id: Option, + pub name: String, + pub content: String, +} + +impl Notification { + pub async fn list(db: &Db) -> Result> { + Ok(sqlx::query_as!(Self, r#"SELECT * FROM notifications"#).fetch_all(db).await?) + } + + pub async fn create(db: &Db, n: &UpdateNotification) -> Result { + let row = sqlx::query!( + r#"INSERT INTO notifications (name, content) + VALUES (?, ?)"#, + n.name, + n.content, + ) + .execute(db) + .await?; + Ok(row.last_insert_rowid()) + } + + pub async fn update(db: &Db, id: i64, n: &UpdateNotification) -> Result<()> { + sqlx::query!( + r#"UPDATE notifications + SET name = ?, content = ? + WHERE id = ?"#, + n.name, + n.content, + id + ) + .execute(db) + .await?; + Ok(()) + } + + pub async fn delete(db: &Db, id: i64) -> Result<()> { + sqlx::query!(r#"DELETE FROM notifications WHERE id = ?"#, id) + .execute(db) + .await?; + Ok(()) + } + + pub async fn lookup_by_id(db: &Db, id: i64) -> Result> { + Ok(sqlx::query_as!(Self, r#"SELECT * FROM notifications WHERE id = ?"#, id) + .fetch_optional(db) + .await?) + } +} diff --git a/src/db/post.rs b/src/db/post.rs new file mode 100644 index 00000000..6731a883 --- /dev/null +++ b/src/db/post.rs @@ -0,0 +1,83 @@ +use crate::prelude::*; + +#[derive(Clone, Debug, sqlx::FromRow, serde::Serialize)] +pub struct Post { + pub id: i64, + pub title: String, + pub slug: String, + pub author: String, + pub content: String, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, +} + +#[derive(serde::Deserialize)] +pub struct UpdatePost { + pub title: String, + pub slug: String, + pub author: String, + pub content: String, +} + +impl Post { + // List all posts. + pub async fn list(db: &Db) -> Result> { + let posts = sqlx::query_as!(Self, "SELECT * FROM posts ORDER BY updated_at DESC") + .fetch_all(db) + .await?; + Ok(posts) + } + + /// Create a new post. + pub async fn create(db: &Db, post: &UpdatePost) -> Result<(i64, NaiveDateTime)> { + let row = sqlx::query!( + r#"INSERT INTO posts + (title, slug, author, content) + VALUES (?, ?, ?, ?) + RETURNING id, updated_at"#, + post.title, + post.slug, + post.author, + post.content, + ) + .fetch_one(db) + .await?; + Ok((row.id, row.updated_at)) + } + + /// Update an existing post. + pub async fn update(db: &Db, id: i64, post: &UpdatePost) -> Result<(i64, NaiveDateTime)> { + let row = sqlx::query!( + r#"UPDATE posts + SET title = ?, + slug = ?, + author = ?, + content = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + RETURNING id, updated_at"#, + post.title, + post.slug, + post.author, + post.content, + id + ) + .fetch_one(db) + .await?; + Ok((row.id, row.updated_at)) + } + + /// Delete a post. + pub async fn delete(db: &Db, id: i64) -> Result<()> { + sqlx::query!("DELETE FROM posts WHERE id = ?", id).execute(db).await?; + Ok(()) + } + + /// Lookup a post by URL, if one exists. + pub async fn lookup_by_slug(db: &Db, slug: &str) -> Result> { + let row = sqlx::query_as!(Self, "SELECT * FROM posts WHERE slug = ?", slug) + .fetch_optional(db) + .await?; + Ok(row) + } +} diff --git a/src/db/rsvp.rs b/src/db/rsvp.rs new file mode 100644 index 00000000..a7ae6452 --- /dev/null +++ b/src/db/rsvp.rs @@ -0,0 +1,356 @@ +use crate::db::event::Event; +use crate::db::rsvp_session::RsvpSession; +use crate::prelude::*; + +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct Rsvp { + pub id: i64, + pub session_id: i64, + + pub spot_id: i64, + pub contribution: i64, + pub user_id: Option, + pub user_version: Option, + + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + pub checkin_at: Option, +} + +#[derive(serde::Deserialize)] +pub struct CreateRsvp { + pub session_id: i64, + pub spot_id: i64, + pub contribution: i64, + pub user_id: Option, + pub user_version: Option, +} + +#[derive(serde::Serialize)] +pub struct AttendeeRsvp { + pub rsvp_id: i64, + pub user_id: Option, + pub spot_name: String, + pub first_name: Option, + pub last_name: Option, + pub email: Option, + pub phone: Option, + pub contribution: i64, +} + +#[derive(serde::Serialize)] +pub struct ContributionRsvp { + pub spot_name: String, + pub first_name: String, + pub last_name: String, + pub email: String, + pub phone: Option, + pub contribution: i64, +} + +#[derive(Clone)] +pub struct EventRsvp { + pub rsvp_id: i64, + pub spot_id: i64, + pub contribution: i64, +} + +pub struct UserRsvp { + pub status: String, + pub email: String, +} + +pub struct AdminAttendeesRsvp { + pub user_id: i64, + pub first_name: String, + pub last_name: String, + pub email: String, + pub guest_of: Option, + + pub spot_name: Option, + pub contribution: i64, + + pub is_manual: bool, + pub created_at: NaiveDateTime, + pub checkin_at: Option, +} + +impl Rsvp { + pub async fn list_for_admin_attendees(db: &Db, event_id: i64) -> Result> { + Ok(sqlx::query_as!( + AdminAttendeesRsvp, + r#" + SELECT + u.id AS user_id, + u.first_name as "first_name!", + u.last_name as "last_name!", + u.email, + CASE + WHEN rs.user_id IS NOT NULL AND rs.user_id != r.user_id + THEN hu.first_name || ' ' || hu.last_name + ELSE NULL + END AS guest_of, + + sp.name AS spot_name, + r.contribution, + + FALSE AS "is_manual!: bool", + r.created_at, + r.checkin_at + FROM rsvps r + JOIN rsvp_sessions rs ON rs.id = r.session_id + JOIN spots sp ON sp.id = r.spot_id + JOIN users u ON u.id = r.user_id + JOIN users hu ON hu.id = rs.user_id + WHERE rs.event_id = ? + AND rs.status IN ('payment_pending', 'payment_confirmed') + + UNION ALL + + SELECT + u.id AS user_id, + u.first_name as "first_name!", + u.last_name as "last_name!", + u.email, + cu.first_name || ' ' || cu.last_name AS guest_of, + + NULL AS spot_name, + 0 AS contribution, + + TRUE AS "is_manual!: bool", + mr.created_at, + mr.checkin_at + FROM manual_rsvps mr + JOIN users u ON u.id = mr.user_id + JOIN users cu ON cu.id = mr.creator_user_id + WHERE mr.event_id = ? + + ORDER BY 9; + "#, + event_id, + event_id + ) + .fetch_all(db) + .await?) + } + pub async fn list_for_session(db: &Db, session_id: i64) -> Result> { + Ok(sqlx::query_as!( + EventRsvp, + r#"SELECT r.id as rsvp_id, r.spot_id, r.contribution + FROM rsvps r + JOIN rsvp_sessions rs ON rs.id = r.session_id + WHERE rs.id = ? + "#, + session_id + ) + .fetch_all(db) + .await?) + } + + pub async fn list_for_attendees(db: &Db, session_id: i64) -> Result> { + Ok(sqlx::query_as!( + AttendeeRsvp, + r#"SELECT + r.id AS rsvp_id, + r.user_id, + s.name AS spot_name, + u.first_name, + u.last_name, + u.email, + u.phone, + r.contribution + FROM rsvps r + JOIN spots s ON s.id = r.spot_id + JOIN rsvp_sessions rs ON rs.id = r.session_id + LEFT JOIN users u ON u.id = r.user_id + WHERE rs.id = ? + "#, + session_id + ) + .fetch_all(db) + .await?) + } + + pub async fn list_for_contributions(db: &Db, session_id: i64) -> Result> { + Ok(sqlx::query_as!( + ContributionRsvp, + r#"SELECT + s.name AS spot_name, + u.first_name AS "first_name!: String", + u.last_name AS "last_name!: String", + u.email, + u.phone, + r.contribution + FROM rsvps r + JOIN spots s ON s.id = r.spot_id + JOIN rsvp_sessions rs ON rs.id = r.session_id + JOIN users u ON u.id = r.user_id + WHERE rs.id = ? + "#, + session_id + ) + .fetch_all(db) + .await?) + } + + /// List reserved spots for an event, excluding a specific session. + /// Only includes rsvps from sessions at CONTRIBUTION status or later. + pub async fn list_reserved_for_event( + db: &Db, event: &Event, session: &RsvpSession, + ) -> Result> { + Ok(sqlx::query_as!( + EventRsvp, + "SELECT r.id as rsvp_id, r.spot_id, r.contribution + FROM rsvps r + JOIN rsvp_sessions rs ON rs.id = r.session_id + WHERE rs.event_id = ? + AND rs.id != ? + AND rs.status IN (?, ?, ?)", + event.id, + session.id, + RsvpSession::CONTRIBUTION, + RsvpSession::PAYMENT_PENDING, + RsvpSession::PAYMENT_CONFIRMED, + ) + .fetch_all(db) + .await?) + } + + pub async fn list_reserved_users_for_event( + db: &Db, event: &Event, session: Option<&RsvpSession>, + ) -> Result> { + let session_id = session.map(|s| s.id).unwrap_or(0); + Ok(sqlx::query_as!( + UserRsvp, + r#"SELECT rs.status, u.email + FROM users u + JOIN rsvps r ON r.user_id = u.id + JOIN rsvp_sessions rs ON rs.id = r.session_id + WHERE rs.event_id = ? + AND rs.id != ? + "#, + event.id, + session_id, + ) + .fetch_all(db) + .await?) + } + + // pub async fn lookup_by_id(db: &Db, id: i64) -> Result> { + // Ok(sqlx::query_as!(Self, r#"SELECT * FROM rsvps WHERE id = ?"#, id) + // .fetch_optional(db) + // .await?) + // } + + pub async fn create(db: &Db, rsvp: CreateRsvp) -> Result { + let row = sqlx::query!( + r#"INSERT INTO rsvps + (session_id, spot_id, contribution, user_id, user_version) + VALUES (?, ?, ?, ?, ?)"#, + rsvp.session_id, + rsvp.spot_id, + rsvp.contribution, + rsvp.user_id, + rsvp.user_version, + ) + .execute(db) + .await?; + Ok(row.last_insert_rowid()) + } + + pub async fn set_user(db: &Db, rsvp_id: i64, user: &User) -> Result<()> { + sqlx::query!( + "UPDATE rsvps + SET user_id = ?, + user_version = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ?", + user.id, + user.version, + rsvp_id, + ) + .execute(db) + .await?; + Ok(()) + } + + pub async fn delete_for_session(db: &Db, session_id: i64) -> Result<()> { + sqlx::query!(r#"DELETE FROM rsvps WHERE session_id = ?"#, session_id) + .execute(db) + .await?; + Ok(()) + } + + /// Set check-in for an attendee by event and user ID. + /// Works for confirmed RSVPs (payment_pending or payment_confirmed). + pub async fn set_checkin_at_for_event(db: &Db, event_id: i64, user_id: i64) -> Result { + let row = sqlx::query!( + r#"UPDATE rsvps SET checkin_at = CURRENT_TIMESTAMP + WHERE id = ( + SELECT r.id FROM rsvps r + JOIN rsvp_sessions rs ON rs.id = r.session_id + WHERE rs.event_id = ? AND r.user_id = ? + AND rs.status IN ('payment_pending', 'payment_confirmed') + ) + RETURNING checkin_at AS 'checkin_at!'"#, + event_id, + user_id + ) + .fetch_one(db) + .await?; + Ok(row.checkin_at) + } + + /// Clear check-in for an attendee by event and user ID. + pub async fn clear_checkin_at_for_event(db: &Db, event_id: i64, user_id: i64) -> Result<()> { + sqlx::query!( + r#"UPDATE rsvps SET checkin_at = NULL + WHERE id = ( + SELECT r.id FROM rsvps r + JOIN rsvp_sessions rs ON rs.id = r.session_id + WHERE rs.event_id = ? AND r.user_id = ? + AND rs.status IN ('payment_pending', 'payment_confirmed') + )"#, + event_id, + user_id + ) + .execute(db) + .await?; + Ok(()) + } + + /// Check if a user has an RSVP for an event. + pub async fn exists_for_event(db: &Db, event_id: i64, user_id: i64) -> Result { + let row = sqlx::query!( + r#"SELECT EXISTS( + SELECT 1 FROM rsvps r + JOIN rsvp_sessions rs ON rs.id = r.session_id + WHERE rs.event_id = ? AND r.user_id = ? + AND rs.status IN ('payment_pending', 'payment_confirmed') + ) as "exists!: bool""#, + event_id, + user_id + ) + .fetch_one(db) + .await?; + Ok(row.exists) + } + + /// Delete an RSVP by event and user ID. + pub async fn delete_for_event(db: &Db, event_id: i64, user_id: i64) -> Result<()> { + sqlx::query!( + r#"DELETE FROM rsvps + WHERE id = ( + SELECT r.id FROM rsvps r + JOIN rsvp_sessions rs ON rs.id = r.session_id + WHERE rs.event_id = ? AND r.user_id = ? + AND rs.status IN ('payment_pending', 'payment_confirmed') + )"#, + event_id, + user_id + ) + .execute(db) + .await?; + Ok(()) + } +} diff --git a/src/db/rsvp_session.rs b/src/db/rsvp_session.rs new file mode 100644 index 00000000..48fa09fc --- /dev/null +++ b/src/db/rsvp_session.rs @@ -0,0 +1,387 @@ +use rand::Rng; +use rand::rngs::OsRng; + +use crate::db::event::Event; +use crate::db::rsvp::ContributionRsvp; +use crate::prelude::*; +use crate::utils::stripe; + +#[derive(Debug, Clone, sqlx::FromRow, serde::Serialize)] +pub struct RsvpSession { + pub id: i64, + pub event_id: i64, + pub token: String, + pub status: String, + + pub user_id: Option, + pub user_version: Option, + + pub stripe_client_secret: Option, + pub stripe_payment_intent_id: Option, + pub stripe_charge_id: Option, + pub stripe_refund_id: Option, + + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, +} + +impl RsvpSession { + pub const SELECTION: &str = "selection"; + pub const ATTENDEES: &str = "attendees"; + pub const CONTRIBUTION: &str = "contribution"; + pub const PAYMENT_PENDING: &str = "payment_pending"; + pub const PAYMENT_CONFIRMED: &str = "payment_confirmed"; + + pub const EXPIRY_TIME_SQL: &str = "-31 minutes"; + pub const STRIPE_EXPIRY_MINUTES: i64 = 30; + + /// Returns true if the stripe client secret is expired (older than 14 minutes). + pub fn is_stripe_expired(&self) -> bool { + let now = Utc::now().naive_utc(); + let age = now - self.updated_at; + age.num_minutes() >= Self::STRIPE_EXPIRY_MINUTES + } + + fn cookie(&self, path: &str) -> String { + Cookie::build(("rsvp_session", &self.token)) + .secure(config().acme.is_some()) + .http_only(true) + .same_site(cookie::SameSite::Strict) + .domain(&config().app.domain) + .path(path) + .to_string() + } + + pub async fn user(&self, db: &Db) -> Result> { + Ok(match self.user_id { + Some(id) => { + let user = User::lookup_by_id(db, id) + .await? + .ok_or_else(|| any!("bad user_id={id} in rsvp_session={}", self.token))?; + Some(user) + } + None => None, + }) + } + + pub async fn lookup_by_id(db: &Db, id: i64) -> Result> { + Ok(sqlx::query_as!(Self, r#"SELECT * FROM rsvp_sessions WHERE id = ?"#, id) + .fetch_optional(db) + .await?) + } + + pub async fn lookup_by_token(db: &Db, token: &str) -> Result> { + Ok(sqlx::query_as!(Self, r#"SELECT * FROM rsvp_sessions WHERE token = ?"#, token) + .fetch_optional(db) + .await?) + } + + pub async fn get_or_create( + db: &Db, user: &Option, session: &Option, event_id: i64, + ) -> Result<[(HeaderName, String); 1]> { + let session = match session { + Some(session) => session.clone(), + None => RsvpSession::create(db, event_id, user).await?, + }; + + let event = Event::lookup_by_id(db, event_id) + .await? + .ok_or_else(|| any!("RsvpSession::get_or_create(): no such event_id={event_id}"))?; + let path = format!("/e/{}", event.slug); + + Ok([(header::SET_COOKIE, session.cookie(&path))]) + } + + pub async fn create(db: &Db, event_id: i64, user: &Option) -> Result { + let token = format!("{:08x}", OsRng.r#gen::()); + let user = user.as_ref(); + let user_id = user.map(|u| u.id); + let user_email = user.map(|u| u.email.as_str()); + let user_version = user.map(|u| u.version); + + let session = sqlx::query_as!( + Self, + r#"INSERT INTO rsvp_sessions + (event_id, token, status, user_id, user_version) + VALUES (?, ?, ?, ?, ?) + RETURNING *"#, + event_id, + token, + Self::SELECTION, + user_id, + user_version, + ) + .fetch_one(db) + .await?; + + tracing::info!( + "Created RSVP session with session_id={} event_id={event_id} user_id={user_id:?} user_email={user_email:?}", + session.id + ); + Ok(session) + } + + pub async fn delete(&self, db: &Db) -> Result<()> { + tracing::info!( + "Deleting RSVP session with session_id={} event_id={} status={:?}", + self.id, + self.event_id, + self.status + ); + sqlx::query!("DELETE FROM rsvps WHERE session_id = ?", self.id) + .execute(db) + .await?; + sqlx::query!("DELETE FROM rsvp_sessions WHERE id = ?", self.id) + .execute(db) + .await?; + Ok(()) + } + + pub async fn takeover_for_event(&self, db: &Db, event: &Event, email: &str) -> Result<()> { + tracing::info!( + "RSVP session takeover with session_id={} event_id={} taking_over_email={email:?}", + self.id, + event.id, + ); + sqlx::query!( + "DELETE FROM rsvp_sessions + WHERE user_id IN ( + SELECT u.id + FROM users u + WHERE u.email = ? COLLATE NOCASE + ) + AND id != ? + AND event_id = ?", + email, + self.id, + event.id + ) + .execute(db) + .await?; + Ok(()) + } + + /// Update + pub async fn set_user(&mut self, db: &Db, user: &User) -> Result<()> { + tracing::info!( + "Setting user on RSVP session with session_id={} event_id={} user_id={} user_email={:?}", + self.id, + self.event_id, + user.id, + user.email + ); + sqlx::query!( + "UPDATE rsvp_sessions + SET user_id = ?, + user_version = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ?", + user.id, + user.version, + self.id, + ) + .execute(db) + .await?; + self.user_id = Some(user.id); + self.user_version = Some(user.version); + Ok(()) + } + + pub async fn set_status(&self, db: &Db, status: &str) -> Result<()> { + tracing::info!( + "RSVP status transition with session_id={} event_id={} user_id={:?} status={:?} -> {status:?}", + self.id, + self.event_id, + self.user_id, + self.status, + ); + sqlx::query!( + "UPDATE rsvp_sessions + SET status = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ?", + status, + self.id + ) + .execute(db) + .await?; + Ok(()) + } + + pub async fn set_payment_intent_id(&self, db: &Db, payment_intent_id: &str) -> Result<()> { + sqlx::query!( + "UPDATE rsvp_sessions + SET stripe_payment_intent_id = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ?", + payment_intent_id, + self.id + ) + .execute(db) + .await?; + Ok(()) + } + + pub async fn set_stripe_client_secret(&mut self, db: &Db, stripe_client_secret: &str) -> Result<()> { + sqlx::query!( + "UPDATE rsvp_sessions + SET stripe_client_secret = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ?", + stripe_client_secret, + self.id + ) + .execute(db) + .await?; + self.stripe_client_secret = Some(stripe_client_secret.into()); + Ok(()) + } + + pub async fn clear_stripe_client_secret(&mut self, db: &Db) -> Result<()> { + sqlx::query!( + "UPDATE rsvp_sessions + SET stripe_client_secret = NULL, updated_at = CURRENT_TIMESTAMP + WHERE id = ?", + self.id + ) + .execute(db) + .await?; + self.stripe_client_secret = None; + Ok(()) + } + + pub async fn delete_expired(db: &Db) -> Result<()> { + sqlx::query!( + "DELETE FROM rsvp_sessions + WHERE status in (?, ?, ?) + AND updated_at < datetime('now', ?)", + Self::SELECTION, + Self::ATTENDEES, + Self::CONTRIBUTION, + Self::EXPIRY_TIME_SQL, + ) + .execute(db) + .await?; + + sqlx::query!( + "DELETE FROM rsvps AS r + WHERE NOT EXISTS ( + SELECT 1 FROM rsvp_sessions s + WHERE s.id = r.session_id + )" + ) + .execute(db) + .await?; + + Ok(()) + } + + pub fn line_items(&self, rsvps: &[ContributionRsvp]) -> Result> { + let mut spot_rsvps: HashMap = Default::default(); + for rsvp in rsvps { + let entry = spot_rsvps.entry(rsvp.spot_name.clone()).or_insert((0, rsvp.contribution)); + entry.0 += 1; // quantity++ + } + + let line_items = spot_rsvps + .into_iter() + .map(|(name, (quantity, price))| stripe::LineItem { name, quantity, price }) + .collect::>(); + + Ok(line_items) + } + + /// List all sessions for debug view (only for events within last 24hrs). + pub async fn list_debug(db: &Db) -> Result> { + let sessions = sqlx::query_as!( + DebugSessionRow, + r#"SELECT + s.id, + s.token, + s.status, + s.created_at, + s.updated_at, + e.title AS event_title, + e.slug AS event_slug, + u.email AS user_email + FROM rsvp_sessions s + JOIN events e ON e.id = s.event_id + LEFT JOIN users u ON u.id = s.user_id + WHERE e.start > datetime('now', '-24 hours') + ORDER BY s.updated_at DESC"# + ) + .fetch_all(db) + .await?; + + let rsvps = sqlx::query_as!( + DebugRsvp, + r#"SELECT + r.session_id, + sp.name AS spot_name, + r.contribution, + u.email + FROM rsvps r + JOIN spots sp ON sp.id = r.spot_id + LEFT JOIN users u ON u.id = r.user_id"# + ) + .fetch_all(db) + .await?; + + let now = Utc::now().naive_utc(); + let mut rsvps_by_session: HashMap> = HashMap::new(); + for rsvp in rsvps { + rsvps_by_session.entry(rsvp.session_id).or_default().push(rsvp); + } + + Ok(sessions + .into_iter() + .map(|s| { + let expires_in = 31 - (now - s.updated_at).num_minutes(); + DebugSession { + id: s.id, + token: s.token, + status: s.status, + created_at: s.created_at, + updated_at: s.updated_at, + event_title: s.event_title, + event_slug: s.event_slug, + user_email: s.user_email, + rsvps: rsvps_by_session.remove(&s.id).unwrap_or_default(), + expires_in, + } + }) + .collect()) + } +} + +struct DebugSessionRow { + id: i64, + token: String, + status: String, + created_at: NaiveDateTime, + updated_at: NaiveDateTime, + event_title: String, + event_slug: String, + user_email: Option, +} + +#[derive(Debug, serde::Serialize)] +pub struct DebugSession { + pub id: i64, + pub token: String, + pub status: String, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + pub event_title: String, + pub event_slug: String, + pub user_email: Option, + pub rsvps: Vec, + pub expires_in: i64, +} + +#[derive(Debug, serde::Serialize)] +pub struct DebugRsvp { + pub session_id: i64, + pub spot_name: String, + pub contribution: i64, + pub email: Option, +} diff --git a/src/db/spot.rs b/src/db/spot.rs new file mode 100644 index 00000000..ab91f515 --- /dev/null +++ b/src/db/spot.rs @@ -0,0 +1,301 @@ +use sqlx::QueryBuilder; + +use crate::db::rsvp::EventRsvp; +use crate::prelude::*; + +/// RSVP counts per spot split by status. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SpotCounts { + /// Confirmed RSVPs (payment_pending or payment_confirmed) + pub rsvp_count: i64, + /// In-progress checkouts (selection, attendees, contribution) + pub cart_count: i64, +} + +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct Spot { + pub id: i64, + + pub name: String, + pub description: String, + pub qty_total: i64, + pub qty_per_person: i64, + pub kind: String, + pub sort: i64, + + // kind = 'fixed' + pub required_contribution: Option, + // kind = 'variable' + pub min_contribution: Option, + pub max_contribution: Option, + pub suggested_contribution: Option, + // kind = 'work' + pub required_notice_hours: Option, + + pub created_at: NaiveDateTime, + pub updated_at: Option, +} + +#[derive(Debug, serde::Deserialize)] +pub struct UpdateSpot { + pub id: Option, + + pub name: String, + pub description: String, + pub qty_total: i64, + pub qty_per_person: i64, + pub kind: String, + pub sort: i64, + + // kind = 'fixed' + pub required_contribution: Option, + // kind = 'variable' + pub min_contribution: Option, + pub max_contribution: Option, + pub suggested_contribution: Option, + // kind = 'work' + pub required_notice_hours: Option, +} + +impl Spot { + /// A free spot. + pub const FREE: &'static str = "free"; + /// A fixed contribution spot. + pub const FIXED: &'static str = "fixed"; + /// A variable contribution spot. + pub const VARIABLE: &'static str = "variable"; + /// A work trade spot. + pub const WORK: &'static str = "work"; + + pub async fn list_ids_for_event(db: &Db, event_id: i64) -> Result> { + Ok(sqlx::query!("SELECT spot_id FROM event_spots WHERE event_id = ?", event_id) + .fetch_all(db) + .await? + .into_iter() + .map(|row| row.spot_id) + .collect()) + } + + pub async fn list_for_event(db: &Db, event_id: i64) -> Result> { + Ok(sqlx::query_as!( + Spot, + r#"SELECT s.* + FROM spots s + JOIN event_spots es ON es.spot_id = s.id + WHERE es.event_id = ? + ORDER BY s.sort + "#, + event_id + ) + .fetch_all(db) + .await?) + } + + /// Get RSVP counts per spot for an event. + /// Returns (rsvp_count, cart_count) where: + /// - rsvp_count: confirmed RSVPs (payment_pending or payment_confirmed) + /// - cart_count: in-progress checkouts (selection, attendees, contribution) + pub async fn rsvp_counts_for_event( + db: &Db, event_id: i64, + ) -> Result> { + let rows = sqlx::query!( + r#"SELECT + r.spot_id, + SUM(CASE WHEN rs.status IN ('payment_pending', 'payment_confirmed') THEN 1 ELSE 0 END) as "rsvp_count!: i64", + SUM(CASE WHEN rs.status IN ('selection', 'attendees', 'contribution') THEN 1 ELSE 0 END) as "cart_count!: i64" + FROM rsvps r + JOIN rsvp_sessions rs ON rs.id = r.session_id + WHERE rs.event_id = ? + GROUP BY r.spot_id"#, + event_id + ) + .fetch_all(db) + .await?; + + Ok(rows + .into_iter() + .map(|r| (r.spot_id, SpotCounts { rsvp_count: r.rsvp_count, cart_count: r.cart_count })) + .collect()) + } + + /// Create a new spot. + pub async fn create(db: &Db, spot: &UpdateSpot) -> Result { + let row = sqlx::query!( + r#"INSERT INTO spots + (name, description, qty_total, qty_per_person, kind, sort, required_contribution, min_contribution, max_contribution, suggested_contribution, required_notice_hours) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#, + spot.name, + spot.description, + spot.qty_total, + spot.qty_per_person, + spot.kind, + spot.sort, + spot.required_contribution, + spot.min_contribution, + spot.max_contribution, + spot.suggested_contribution, + spot.required_notice_hours, + ) + .execute(db) + .await?; + + Ok(row.last_insert_rowid()) + } + + /// Update an existing spot. + pub async fn update(db: &Db, id: i64, spot: &UpdateSpot) -> Result<()> { + sqlx::query!( + "UPDATE spots + SET name = ?, + description = ?, + qty_total = ?, + qty_per_person = ?, + kind = ?, + sort = ?, + required_contribution = ?, + min_contribution = ?, + max_contribution = ?, + suggested_contribution = ?, + required_notice_hours = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ?", + spot.name, + spot.description, + spot.qty_total, + spot.qty_per_person, + spot.kind, + spot.sort, + spot.required_contribution, + spot.min_contribution, + spot.max_contribution, + spot.suggested_contribution, + spot.required_notice_hours, + id + ) + .execute(db) + .await?; + + Ok(()) + } + + pub async fn add_to_event(db: &Db, event_id: i64, spot_ids: Vec) -> Result<()> { + if spot_ids.is_empty() { + return Ok(()); + } + + QueryBuilder::new("INSERT INTO event_spots (event_id, spot_id) ") + .push_values(spot_ids, |mut b, spot_id| { + b.push_bind(event_id).push_bind(spot_id); + }) + .build() + .execute(db) + .await?; + Ok(()) + } + + pub async fn remove_from_event(db: &Db, event_id: i64, spot_ids: Vec) -> Result<()> { + if spot_ids.is_empty() { + return Ok(()); + } + + // Remove the event_spots associations + QueryBuilder::new("DELETE FROM event_spots WHERE event_id = ") + .push_bind(event_id) + .push("AND spot_id IN ") + .push_tuples(&spot_ids, |mut b, spot_id| { + b.push_bind(spot_id); + }) + .build() + .execute(db) + .await?; + + Ok(()) + } + + /// Duplicate all spots from one event to another. + /// Creates new spot records (copies) and links them to the new event. + pub async fn duplicate_for_event(db: &Db, source_event_id: i64, target_event_id: i64) -> Result<()> { + let spots = Spot::list_for_event(db, source_event_id).await?; + let mut new_spot_ids = Vec::with_capacity(spots.len()); + + for spot in spots { + let new_spot_id = sqlx::query!( + r#"INSERT INTO spots + (name, description, qty_total, qty_per_person, kind, sort, + required_contribution, min_contribution, max_contribution, + suggested_contribution, required_notice_hours) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#, + spot.name, + spot.description, + spot.qty_total, + spot.qty_per_person, + spot.kind, + spot.sort, + spot.required_contribution, + spot.min_contribution, + spot.max_contribution, + spot.suggested_contribution, + spot.required_notice_hours, + ) + .execute(db) + .await? + .last_insert_rowid(); + + new_spot_ids.push(new_spot_id); + } + + Spot::add_to_event(db, target_event_id, new_spot_ids).await?; + Ok(()) + } +} + +#[derive(Debug, serde::Serialize)] +pub struct SpotStats { + pub stats: HashMap>, +} + +// Stat about VARIABLE spot, such as min/median/max contribution. +#[derive(Debug, serde::Serialize)] +pub struct SpotStat { + pub name: String, + pub value: i64, +} + +impl Spot { + /// Compute contribution statistics for VARIABLE spots given a list of rsvps. + pub fn stats(spots: &[Spot], rsvps: &[EventRsvp]) -> SpotStats { + let mut contributions: HashMap> = HashMap::default(); + for rsvp in rsvps { + contributions.entry(rsvp.spot_id).or_default().push(rsvp.contribution); + } + + let mut variable_stats = HashMap::default(); + for spot in spots.iter().filter(|s| s.kind == Spot::VARIABLE) { + let Some(values) = contributions.get_mut(&spot.id) else { + continue; + }; + + let n = values.len(); + values.sort_unstable(); + let median = if n.is_multiple_of(2) { + let l = values[n / 2 - 1]; + let r = values[n / 2]; + (l + r) / 2 + } else { + values[n / 2] + }; + let max = values.last().copied().unwrap(); + + // Only add the max if it's different from the median to avoid clutter + let mut stats = vec![]; + stats.push(SpotStat { name: "Median".into(), value: median }); + if max > median { + stats.push(SpotStat { name: "Max".into(), value: max }); + } + + variable_stats.insert(spot.id, stats); + } + + SpotStats { stats: variable_stats } + } +} diff --git a/src/db/token.rs b/src/db/token.rs new file mode 100644 index 00000000..f9d1578c --- /dev/null +++ b/src/db/token.rs @@ -0,0 +1,61 @@ +use rand::Rng; +use rand::rngs::OsRng; + +use crate::prelude::*; + +/// A token which can be used to authenticate as a user. +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct SessionToken { + pub user_id: i64, + pub token: String, + pub created_at: NaiveDateTime, +} + +/// A token which can be used to login as a user. +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct LoginToken { + pub user_id: i64, + pub token: String, + pub created_at: NaiveDateTime, + pub used_at: Option, +} + +impl SessionToken { + /// Create a new session token for a user. + pub async fn create(db: &Db, user: &User) -> Result { + let token = format!("{:08x}", OsRng.r#gen::()); + + sqlx::query!("INSERT INTO session_tokens (user_id, token) VALUES (?, ?)", user.id, token) + .execute(db) + .await?; + + Ok(token) + } +} + +impl LoginToken { + /// Create a new login token for an email address. + pub async fn create(db: &Db, user: &User) -> Result { + let token = format!("{:08x}", OsRng.r#gen::()); + + sqlx::query!("INSERT INTO login_tokens (user_id, token) VALUES (?, ?)", user.id, token) + .execute(db) + .await?; + + Ok(token) + } + + pub async fn delete_by_user(db: &Db, user: &User) -> Result<()> { + sqlx::query!("DELETE FROM login_tokens WHERE user_id = ?", user.id) + .execute(db) + .await?; + Ok(()) + } + + pub async fn delete_by_token(db: &Db, token: &str) -> Result<()> { + sqlx::query!("DELETE FROM login_tokens WHERE token = ?", token) + .execute(db) + .await?; + Ok(()) + } +} diff --git a/src/db/user.rs b/src/db/user.rs new file mode 100644 index 00000000..f3cd8ea7 --- /dev/null +++ b/src/db/user.rs @@ -0,0 +1,298 @@ +use crate::prelude::*; + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct User { + pub id: i64, + pub email: String, + pub first_name: Option, + pub last_name: Option, + pub phone: Option, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + + pub version: i64, + pub roles: Vec, +} + +#[derive(Clone, Debug, serde::Deserialize)] +pub struct CreateUser { + pub email: String, + pub first_name: Option, + pub last_name: Option, + pub phone: Option, +} + +#[derive(Clone, Debug, serde::Deserialize)] +pub struct UpdateUser { + pub email: String, + pub first_name: Option, + pub last_name: Option, + pub phone: Option, +} + +#[macro_export] +macro_rules! map_row { + ($row:expr) => { + User { + id: $row.id, + email: $row.email, + first_name: $row.first_name, + last_name: $row.last_name, + phone: $row.phone, + created_at: $row.created_at, + updated_at: $row.updated_at, + + version: $row.version, + roles: $row.roles.split(',').filter(|s| !s.is_empty()).map(|s| s.to_string()).collect(), + } + }; +} + +macro_rules! map_row_fuck { + ($row:expr) => { + User { + id: $row.id.unwrap(), + email: $row.email.unwrap(), + first_name: $row.first_name, + last_name: $row.last_name, + phone: $row.phone, + created_at: $row.created_at.unwrap(), + updated_at: $row.updated_at.unwrap(), + + version: $row.version, + roles: $row.roles.split(',').filter(|s| !s.is_empty()).map(|s| s.to_string()).collect(), + } + }; +} + +impl User { + /// Full access to everything. + pub const ADMIN: &'static str = "admin"; + /// Can manage posts. + pub const WRITER: &'static str = "writer"; + + pub async fn get_or_create(db: &Db, info: &CreateUser) -> Result { + Ok(match Self::lookup_by_email(db, &info.email).await? { + Some(user) => user, + None => Self::create(db, info).await?, + }) + } + + pub async fn update_or_create(db: &Db, info: &CreateUser) -> Result { + Ok(match Self::lookup_by_email(db, &info.email).await? { + Some(user) => { + user.update( + db, + &UpdateUser { + email: info.email.clone(), + first_name: info.first_name.clone(), + last_name: info.last_name.clone(), + phone: info.phone.clone(), + }, + ) + .await? + } + None => Self::create(db, info).await?, + }) + } + + /// Create a new user. + pub async fn create(db: &Db, user: &CreateUser) -> Result { + let row = sqlx::query!( + r#"INSERT INTO users + (email, first_name, last_name, phone) + VALUES (?, ?, ?, ?) + RETURNING *, 0 as version, '' as roles + "#, + user.email, + user.first_name, + user.last_name, + user.phone + ) + .fetch_one(db) + .await?; + + sqlx::query!( + r#"INSERT INTO user_history (user_id, version, email, first_name, last_name, phone, created_at) + VALUES (?, 0, ?, ?, ?, ?, ?)"#, + row.id, + user.email, + user.first_name, + user.last_name, + user.phone, + row.created_at + ) + .execute(db) + .await?; + + Ok(map_row!(row)) + } + + pub async fn update(&self, db: &Db, info: &UpdateUser) -> Result { + let unchanged = info.first_name == self.first_name + && info.last_name == self.last_name + && info.phone == self.phone; + if unchanged { + return Ok(self.clone()); + } + + let new_version = self.version + 1; + + sqlx::query!( + r#"INSERT INTO user_history (user_id, version, email, first_name, last_name, phone) + VALUES (?, ?, ?, ?, ?, ?)"#, + self.id, + new_version, + info.email, + info.first_name, + info.last_name, + info.phone, + ) + .execute(db) + .await?; + + sqlx::query!( + r#"UPDATE users + SET email = ?, + first_name = ?, + last_name = ?, + phone = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ?"#, + info.email, + info.first_name, + info.last_name, + info.phone, + self.id + ) + .execute(db) + .await?; + + // Get new version + Ok(Self::lookup_by_id(db, self.id).await?.unwrap()) + } + + // pub async fn add_role(db: &Db, user_id: i64, role: &str) -> Result<()> { + // sqlx::query!(r#"INSERT INTO user_roles (user_id, role) VALUES (?, ?)"#, user_id, role) + // .execute(db) + // .await?; + // Ok(()) + // } + + /// Lookup a user by id, if one exists. + pub async fn lookup_by_id(db: &Db, id: i64) -> Result> { + let row = sqlx::query!( + r#" + SELECT + u.*, + COALESCE(MAX(h.version), 0) as "version!: i64", + COALESCE(GROUP_CONCAT(r.role), '') AS "roles!: String" + FROM users u + LEFT JOIN user_roles r ON r.user_id = u.id + JOIN user_history h ON h.user_id = u.id + WHERE u.id = ? + GROUP BY u.id + "#, + id + ) + .fetch_optional(db) + .await?; + Ok(row.map(|r| map_row_fuck!(r))) + } + + /// Lookup a user by email address, if one exists. + pub async fn lookup_by_email(db: &Db, email: &str) -> Result> { + let row = sqlx::query!( + r#" + SELECT + u.*, + COALESCE(MAX(h.version), 0) as "version!: i64", + COALESCE(GROUP_CONCAT(r.role), '') AS "roles!: String" + FROM users u + LEFT JOIN user_roles r ON r.user_id = u.id + JOIN user_history h ON h.user_id = u.id + WHERE u.email = ? COLLATE NOCASE + GROUP BY u.id + "#, + email + ) + .fetch_optional(db) + .await?; + Ok(row.map(|r| map_row_fuck!(r))) + } + /// Lookup a user by a login token, if it's valid. + pub async fn lookup_by_login_token(db: &Db, token: &str) -> Result> { + // Weird workaround for sqlx incorrectly inferring nullability for joins + // not sure why this is needed here and not below + // use the "!" syntax to force the column to be interpreted as non-null + // https://github.com/launchbadge/sqlx/issues/2127 + let row = sqlx::query!( + r#" + SELECT + u.*, + COALESCE(MAX(h.version), 0) as "version!: i64", + COALESCE(GROUP_CONCAT(r.role), '') AS "roles!: String" + FROM users u + LEFT JOIN login_tokens t ON t.user_id = u.id + JOIN user_history h ON h.user_id = u.id + LEFT JOIN user_roles r ON r.user_id = u.id + WHERE t.token = ? + GROUP BY u.id"#, + token + ) + .fetch_optional(db) + .await?; + + Ok(row.map(|r| map_row_fuck!(r))) + } + /// Lookup a user by a session token, if it's valid. + pub async fn lookup_by_session_token(db: &Db, token: &str) -> Result> { + let row = sqlx::query!( + r#" + SELECT + u.*, + COALESCE(MAX(h.version), 0) as "version!: i64", + COALESCE(GROUP_CONCAT(r.role), '') AS "roles!: String" + FROM users u + LEFT JOIN session_tokens t ON t.user_id = u.id + JOIN user_history h ON h.user_id = u.id + LEFT JOIN user_roles r ON r.user_id = u.id + WHERE t.token = ? + GROUP BY u.id + "#, + token + ) + .fetch_optional(db) + .await?; + Ok(row.map(|r| map_row!(r))) + } + + pub async fn lookup_by_list_id(db: &Db, list_id: i64) -> Result> { + let rows = sqlx::query!( + r#" + SELECT + u.*, + COALESCE(MAX(h.version), 0) as "version!: i64", + COALESCE(GROUP_CONCAT(r.role), '') AS "roles!: String" + FROM list_members lm + JOIN users u ON u.id = lm.user_id + JOIN user_history h ON h.user_id = u.id + LEFT JOIN user_roles r ON r.user_id = u.id + WHERE lm.list_id = ? + GROUP BY u.id + "#, + list_id, + ) + .fetch_all(db) + .await?; + Ok(rows.into_iter().map(|r| map_row!(r)).collect()) + } + + pub fn has_role(&self, role: &str) -> bool { + self.roles.iter().any(|r| r == role) + } + + pub fn has_staff_role(&self) -> bool { + self.roles.iter().any(|r| [Self::ADMIN, Self::WRITER].contains(&&**r)) + } +} diff --git a/src/jobs.rs b/src/jobs.rs new file mode 100644 index 00000000..a47294fb --- /dev/null +++ b/src/jobs.rs @@ -0,0 +1,24 @@ +use std::sync::Arc; + +use tokio_schedule::{Job, every}; + +use crate::Config; +use crate::db::rsvp_session::RsvpSession; +use crate::utils::types::SharedAppState; + +pub async fn init(state: SharedAppState, config: Config) { + let config = Arc::new(config.clone()); + let tz = config.app.tz; + + let state_ = state.clone(); + tokio::spawn( + every(1) + .minute() + .at(0) + .in_timezone(&tz) + .perform(move || expire_rsvp_sessions(state_.clone())), + ); +} +async fn expire_rsvp_sessions(state: SharedAppState) { + let _ = RsvpSession::delete_expired(&state.db).await; +} diff --git a/src/main.rs b/src/main.rs index a9791970..999c514a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,24 +1,78 @@ -use anyhow::{Context, Result}; +// No idea why this fires... +#![allow(redundant_semicolons)] mod app; +mod db; +mod jobs; +mod prelude; mod utils; -use axum::{handler::HandlerWithoutStateExt, response::Redirect}; +use std::net::SocketAddr; + +use axum::handler::HandlerWithoutStateExt; +use axum::response::Redirect; use axum_server::tls_rustls::RustlsConfig; use futures::StreamExt; +use tracing::Level; +use tracing::level_filters::LevelFilter; +use tracing_subscriber::layer::SubscriberExt as _; +use tracing_subscriber::util::SubscriberInitExt as _; use utils::config::*; +use crate::prelude::*; + #[tokio::main] async fn main() -> Result<()> { - tracing_subscriber::fmt().init(); + // TODO(sam) is it possible to filter the logs from ServeDir? + let log_filter = tracing_subscriber::filter::Targets::default() + .with_target("h2", LevelFilter::OFF) + .with_target("globset", LevelFilter::OFF) + .with_target("rustls", LevelFilter::OFF) + .with_default(Level::INFO); + + tracing_subscriber::registry() + .with( + tracing_subscriber::fmt::layer() + .pretty() + .with_target(true) + .with_line_number(true), + ) + .with(log_filter) + .with(utils::sentry::layer()) + .init(); // Load the server config - let file = std::env::args().nth(1).context("usage: wlsd ")?; - let config = Config::load(&file).await?; + #[cfg(debug_assertions)] + let config = { + let Some(file) = std::env::args().nth(1) else { + bail!("usage: lsd "); + }; + let config = Config::load(&file).await?; + tracing::info!("Loaded config at {file:?}: {config:#?}"); + config + }; + #[cfg(not(debug_assertions))] + let config = { + let config = Config::parse(include_str!("../config/prod.toml"))?; + tracing::info!("Loaded embedded config: {config:#?}"); + config + }; + // Make it visible globally + CONFIG.set(config.clone()).unwrap_or_else(|_| unreachable!()); - let app = app::build(config.clone()).await?.into_make_service(); + // Setup error logging + if let Some(config) = &config.sentry { + tracing::info!("Sentry enabled"); + utils::sentry::init(config); + } + + let (router, state) = app::build(config.clone()).await?; + let app = router.into_make_service_with_connect_info::(); tracing::info!("Live at {}", &config.app.url); + // Spawn periodic jobs + jobs::init(state, config.clone()).await; + // Spawn an auxillary HTTP server which just redirects to HTTPS tokio::spawn(async move { let redirect = move || async move { Redirect::permanent(&config.app.url) }; @@ -42,7 +96,7 @@ async fn main() -> Result<()> { tokio::spawn(async move { loop { match acme.next().await.unwrap() { - Ok(ok) => tracing::info!("acme: {:?}", ok), + Ok(ok) => tracing::debug!("acme: {:?}", ok), Err(err) => tracing::error!("acme: {}", err), } } diff --git a/src/prelude.rs b/src/prelude.rs new file mode 100644 index 00000000..4433cb92 --- /dev/null +++ b/src/prelude.rs @@ -0,0 +1,29 @@ +pub use std::collections::HashMap; +pub use std::convert::Infallible; +pub use std::fmt::Write; +pub use std::sync::Arc; +pub use std::time::Duration; + +pub use askama::Template; +pub use askama_web::WebTemplate; +pub use axum::extract::{Path, Query, Request, State}; +pub use axum::http::request::Parts; +pub use axum::http::{HeaderName, HeaderValue, StatusCode, header}; +pub use axum::middleware::Next; +pub use axum::response::{IntoResponse, Redirect, Response}; +pub use axum::routing::{delete, get, post}; +pub use axum::{Form, Json}; +pub use axum_extra::extract::CookieJar; +pub use chrono::{NaiveDateTime, Utc}; +pub use cookie::Cookie; +pub use futures::{Stream, StreamExt as _}; +pub use serde_json::json; + +pub use crate::db::Db; +pub use crate::db::email::Email; +pub use crate::db::user::User; +pub use crate::utils::config::{Config, config}; +pub use crate::utils::error::*; +pub use crate::utils::routing::{AppRouter, AxumRouter}; +pub use crate::utils::templates::filters; +pub use crate::utils::types::SharedAppState; diff --git a/src/utils/cloudflare.rs b/src/utils/cloudflare.rs new file mode 100644 index 00000000..8ceebc87 --- /dev/null +++ b/src/utils/cloudflare.rs @@ -0,0 +1,71 @@ +use std::net::IpAddr; + +use chrono::DateTime; + +use crate::prelude::*; + +pub struct Cloudflare { + app_domain: String, + turnstile_secret_key: String, + http: reqwest::Client, +} + +impl Cloudflare { + pub fn new(config: &Config) -> Result { + Ok(Self { + app_domain: config.app.domain.clone(), + turnstile_secret_key: config.cloudflare.turnstile_secret_key.clone(), + http: reqwest::Client::builder().timeout(Duration::from_secs(10)).build()?, + }) + } + + /// Validates a Cloudflare Turnstile token from a client. + pub async fn validate_turnstile(&self, client_ip: IpAddr, token: &str) -> Result { + #[derive(serde::Serialize)] + struct Request { + secret: String, + response: String, + remoteip: String, + } + let req = self + .http + .post("https://challenges.cloudflare.com/turnstile/v0/siteverify") + .json(&Request { + secret: self.turnstile_secret_key.clone(), + response: token.into(), + remoteip: client_ip.to_string(), + }) + .send() + .await?; + + #[derive(serde::Deserialize, serde::Serialize, Debug)] + struct Response { + success: bool, + hostname: Option, + challenge_ts: Option>, + #[serde(default, rename = "error-codes")] + error_codes: Vec, + } + let res: Response = req.json().await?; + + // If things go wrong we could look in here, but no sense in printing bot spam. + /* if !res.error_codes.is_empty() { ... } */ + + let challenge_ok = res.success; + let domain_ok = match self.app_domain.as_str() { + "localhost" => true, + domain => res.hostname.is_some_and(|host| host == domain), + }; + let age_ok = res + .challenge_ts + .is_some_and(|ts| Utc::now().signed_duration_since(ts).num_minutes() < 5); + let ok = challenge_ok && domain_ok && age_ok; + + tracing::info!( + "Turnstile token={token} client_ip={client_ip} -> ok={ok} challenge_ok={challenge_ok} domain_ok={domain_ok} age_ok={age_ok} errors={:?}", + res.error_codes + ); + + Ok(ok) + } +} diff --git a/src/utils/config.rs b/src/utils/config.rs index acbdceb5..b6da27f1 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -1,50 +1,126 @@ -use anyhow::{Context, Result}; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::OnceLock; + +use chrono_tz::Tz; use lettre::message::Mailbox; -use std::{net::SocketAddr, path::PathBuf}; + +use crate::prelude::*; + +/// Global app config, set once at startup. +pub static CONFIG: OnceLock = OnceLock::new(); +pub fn config() -> &'static Config { + CONFIG.get().unwrap() +} impl Config { + /// Load a `.toml` file from disk and parse it as a [`Config`]. + #[allow(unused)] pub async fn load(file: &str) -> Result { - async fn load_inner(file: &str) -> Result { - let contents = tokio::fs::read_to_string(file).await?; - Ok(toml::from_str(&contents)?) - } - load_inner(file).await.with_context(|| format!("loading config={file}")) + let contents = tokio::fs::read_to_string(file).await?; + Ok(toml::from_str(&contents)?) + } + + /// Parse a string as a [`Config`]. + #[allow(unused)] + pub fn parse(contents: &str) -> Result { + Ok(toml::from_str(contents)?) } } +/// Bag of app configuration values, parsed from a TOML file with serde. #[derive(Clone, Debug, serde::Deserialize)] pub struct Config { pub app: AppConfig, + pub db: DbConfig, pub net: NetConfig, pub acme: Option, pub email: EmailConfig, + pub stripe: StripeConfig, + pub cloudflare: CloudflareConfig, + pub sentry: Option, } +/// Webapp configuration. #[derive(Clone, Debug, serde::Deserialize)] pub struct AppConfig { + /// Public facing domain, e.g. `site.com`. + pub domain: String, + /// Public facing URL, e.g. `https://site.com`. pub url: String, - pub db: PathBuf, + /// Local timezone. + pub tz: Tz, + /// How long until a login session expires. + pub session_expiry_days: u32, +} + +/// Database configuration. +#[derive(Clone, Debug, serde::Deserialize)] +pub struct DbConfig { + /// Path to sqlite3 database file. + pub file: PathBuf, + pub seed_data: Option, } +/// Networking configuration. #[derive(Clone, Debug, serde::Deserialize)] pub struct NetConfig { + /// HTTP server bind address. pub http_addr: SocketAddr, + /// HTTS server bind address. pub https_addr: SocketAddr, } /// LetsEncrypt ACME TLS certificate configuration. #[derive(Clone, Debug, serde::Deserialize)] pub struct AcmeConfig { + /// Domain to request a cert for. pub domain: String, + /// Contact email. pub email: String, - /// Directory where certificates and credentials are stored. + /// Directory to store certs and credentials in. pub dir: String, /// Whether to use the production or staging ACME server. pub prod: bool, } +/// Email configuration. #[derive(Clone, Debug, serde::Deserialize)] pub struct EmailConfig { - pub addr: String, + /// SMTP address, starting with `smtp://`. + pub smtp_addr: String, + /// SMTP username. + pub smtp_username: Option, + /// SMTP password. + pub smtp_password: Option, + /// Maximum number of emails to send per second. + #[serde(default = "default_ratelimit")] + pub ratelimit: usize, + /// Mailbox to send email from. pub from: Mailbox, + /// Mailbox to list as ReplyTo for the newsletter. + pub newsletter_reply_to: Option, + /// Mailbox to send contact form submissions to. + pub contact_to: Option, +} +fn default_ratelimit() -> usize { + 10 +} + +#[derive(Clone, Debug, serde::Deserialize)] +pub struct StripeConfig { + pub publishable_key: String, + pub secret_key: String, + pub webhook_key: String, +} + +#[derive(Clone, Debug, serde::Deserialize)] +pub struct CloudflareConfig { + pub turnstile_site_key: String, + pub turnstile_secret_key: String, +} + +#[derive(Clone, Debug, serde::Deserialize)] +pub struct SentryConfig { + pub dsn: String, } diff --git a/src/utils/db.rs b/src/utils/db.rs deleted file mode 100644 index 811c5aa0..00000000 --- a/src/utils/db.rs +++ /dev/null @@ -1,144 +0,0 @@ -use std::path::Path; - -use anyhow::Result; -use lettre::message::Mailbox; -use rand::{rngs::OsRng, Rng as _}; -use sqlx::{migrate::MigrateDatabase, Sqlite, SqlitePool}; - -#[derive(Clone)] -pub struct Db { - pool: SqlitePool, -} - -#[derive(sqlx::FromRow, serde::Serialize)] -pub struct User { - pub id: i64, - pub first_name: String, - pub last_name: String, - pub email: String, - pub created_at: String, -} - -impl Db { - pub async fn connect(file: &Path) -> Result { - let url = format!("sqlite://{}", file.display()); - if !Sqlite::database_exists(&url).await? { - Sqlite::create_database(&url).await?; - } - let pool = SqlitePool::connect(&url).await?; - - let db = Self { pool }; - db.migrate().await?; - Ok(db) - } - - async fn migrate(&self) -> Result<()> { - sqlx::query( - "CREATE TABLE IF NOT EXISTS users ( \ - id INTEGER PRIMARY KEY NOT NULL, \ - first_name TEXT NOT NULL, \ - last_name TEXT NOT NULL, \ - email TEXT NOT NULL, \ - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP \ - )", - ) - .execute(&self.pool) - .await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS login_tokens ( \ - id INTEGER PRIMARY KEY NOT NULL, \ - email TEXT NOT NULL, \ - token TEXT NOT NULL, \ - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP \ - )", - ) - .execute(&self.pool) - .await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS session_tokens ( \ - id INTEGER PRIMARY KEY NOT NULL, \ - user_id INTEGER NOT NULL, \ - token TEXT NOT NULL, \ - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, \ - FOREIGN KEY (user_id) REFERENCES users(id) \ - )", - ) - .execute(&self.pool) - .await?; - - Ok(()) - } - - pub async fn create_user(&self, first_name: &str, last_name: &str, email: &str) -> Result { - let row = sqlx::query("INSERT INTO users (first_name, last_name, email) VALUES (?, ?, ?)") - .bind(first_name) - .bind(last_name) - .bind(email) - .execute(&self.pool) - .await?; - Ok(row.last_insert_rowid()) - } - pub async fn lookup_user_by_email(&self, email: &Mailbox) -> Result> { - let row = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = ?") - .bind(email.email.to_string()) - .fetch_optional(&self.pool) - .await?; - Ok(row) - } - pub async fn lookup_user_by_login_token(&self, token: &str) -> Result> { - let row = sqlx::query_as::<_, User>( - "SELECT u.* \ - FROM login_tokens t \ - LEFT JOIN users u on u.email = t.email \ - WHERE t.token = ?", - ) - .bind(token) - .fetch_optional(&self.pool) - .await?; - Ok(row) - } - pub async fn lookup_user_from_session_token(&self, token: &str) -> Result> { - let user = sqlx::query_as::<_, User>( - "SELECT u.* \ - FROM session_tokens t \ - JOIN users u on u.id = t.user_id \ - WHERE token = ?", - ) - .bind(token) - .fetch_optional(&self.pool) - .await?; - Ok(user) - } - - pub async fn create_session_token(&self, user_id: i64) -> Result { - let token = format!("{:08x}", OsRng.gen::()); - - sqlx::query("INSERT INTO session_tokens (user_id, token) VALUES (?, ?)") - .bind(user_id) - .bind(&token) - .execute(&self.pool) - .await?; - - Ok(token) - } - pub async fn create_login_token(&self, email: &Mailbox) -> Result { - let token = format!("{:08x}", OsRng.gen::()); - - sqlx::query("INSERT INTO login_tokens (email, token) VALUES (?, ?)") - .bind(email.email.to_string()) - .bind(&token) - .execute(&self.pool) - .await?; - - Ok(token) - } - pub async fn lookup_email_by_login_token(&self, token: &str) -> Result> { - let row = sqlx::query_as::<_, (String,)>("SELECT email FROM login_tokens WHERE token = ?") - .bind(token) - .fetch_optional(&self.pool) - .await?; - Ok(row.map(|r| r.0)) - } -} diff --git a/src/utils/editor.rs b/src/utils/editor.rs new file mode 100644 index 00000000..591501be --- /dev/null +++ b/src/utils/editor.rs @@ -0,0 +1,16 @@ +use crate::prelude::*; + +pub struct EditorContent { + pub html: String, + pub updated_at: NaiveDateTime, +} +pub struct Editor { + /// Where the content gets POSTed to. + /// The string "{id}" is replaced with the current entity id. + /// Returns JSON, either {id: 123} or {error: ""} + pub url: &'static str, + pub snapshot_prefix: &'static str, + + pub entity_id: Option, + pub content: Option, +} diff --git a/src/utils/email.rs b/src/utils/email.rs deleted file mode 100644 index 9ffe592c..00000000 --- a/src/utils/email.rs +++ /dev/null @@ -1,31 +0,0 @@ -use anyhow::Result; -use lettre::{ - message::{header::ContentType, Mailbox, MessageBuilder}, - Message, SmtpTransport, Transport, -}; - -use crate::EmailConfig; - -#[derive(Clone)] -pub struct Email { - addr: String, - from: Mailbox, -} - -impl Email { - pub async fn connect(config: EmailConfig) -> Result { - // we need this for smtps - let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - Ok(Self { addr: config.addr, from: config.from }) - } - - pub fn builder(&self) -> MessageBuilder { - Message::builder().from(self.from.clone()).header(ContentType::TEXT_PLAIN) - } - - pub async fn send(&self, message: Message) -> Result<()> { - let transport = SmtpTransport::from_url(&self.addr)?.build(); - transport.send(&message)?; - Ok(()) - } -} diff --git a/src/utils/emailer.rs b/src/utils/emailer.rs new file mode 100644 index 00000000..611fb932 --- /dev/null +++ b/src/utils/emailer.rs @@ -0,0 +1,69 @@ +use lettre::message::{Mailbox, MessageBuilder}; +use lettre::transport::smtp::authentication::Credentials; +use lettre::{Message, SmtpTransport, Transport}; + +use crate::EmailConfig; +use crate::prelude::*; + +/// Email client. +#[derive(Clone)] +pub struct Emailer { + /// Mailbox to send email from. + from: Mailbox, + /// Underlying SMTPS transport. + transport: SmtpTransport, + /// Batch size for bulk email sending. + batch_size: usize, +} + +impl Emailer { + pub async fn connect(config: EmailConfig) -> Result { + // `lettre` requires a default provider to be installed to use SMTPS. + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + + let mut transport = SmtpTransport::from_url(&config.smtp_addr)?; + if let (Some(username), Some(password)) = (config.smtp_username, config.smtp_password) { + transport = transport.credentials(Credentials::new(username, password)); + } + let transport = transport.build(); + let batch_size = config.ratelimit; + + Ok(Self { transport, from: config.from, batch_size }) + } + + pub fn builder(&self) -> MessageBuilder { + Message::builder().from(self.from.clone()) + } + + pub async fn send(&self, message: &Message) -> Result<()> { + self.transport.send(message)?; + Ok(()) + } + + pub async fn send_batch( + &self, state: SharedAppState, messages: Vec, + ) -> impl Stream> + use<> { + async_stream::stream! { + let mut progress = Progress { sent: 0, remaining: messages.len() as u32 }; + + for batch in messages.chunks(state.mailer.batch_size) { + for message in batch { + let result = state.mailer.send(message).await; + progress.sent += 1; + progress.remaining -= 1; + + yield result.map(|_| progress); + + tokio::time::sleep(Duration::from_millis(20)).await; + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct Progress { + pub sent: u32, + pub remaining: u32, +} diff --git a/src/utils/error.rs b/src/utils/error.rs new file mode 100644 index 00000000..3f41ed5e --- /dev/null +++ b/src/utils/error.rs @@ -0,0 +1,294 @@ +use std::error::Error; + +use backtrace::Backtrace; + +use crate::prelude::*; + +pub type Result = std::result::Result; + +#[derive(Debug)] +pub struct AnyError { + message: String, + backtrace: Backtrace, +} +impl AnyError { + pub fn new(message: impl Into) -> Self { + Self { message: message.into(), backtrace: Backtrace::new() } + } + pub fn message(&self) -> &str { + &self.message + } + pub fn backtrace(&self) -> &Backtrace { + &self.backtrace + } +} + +// Wrapper for Into> since we can't impl Error directly on would conflict with the blanket From +#[derive(Debug)] +pub struct AnyErrorWrapper(AnyError); +impl std::fmt::Display for AnyErrorWrapper { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.message.fmt(f) + } +} +impl std::error::Error for AnyErrorWrapper {} +impl From for Box { + fn from(e: AnyError) -> Self { + Box::new(AnyErrorWrapper(e)) + } +} + +impl From for AnyError { + #[track_caller] + fn from(e: E) -> Self { + let backtrace = Backtrace::new(); + + let mut message = format!("{e}"); + let mut curr: &dyn Error = &e; + while let Some(prev) = curr.source() { + write!(message, ": {prev}").unwrap(); + curr = prev; + } + + Self { message, backtrace } + } +} + +#[macro_export] +macro_rules! bail { + ( $fmt:expr ) => { + return Err(AnyError::new($fmt).into()); + }; + ( $fmt:expr, $($arg:expr),* $(,)?) => { + return Err(AnyError::new(format!("{}", format_args!($fmt, $($arg),*))).into()); + } +} +pub use bail; + +#[macro_export] +macro_rules! any { + ( $fmt:expr ) => { + AnyError::new($fmt) + }; + ( $fmt:expr, $($arg:expr),* $(,)?) => { + AnyError::new(format!("{}", format_args!($fmt, $($arg),*))) + } +} +pub use any; + +/// Semantic app error. +/// * For HTML responses, gets templated into a nice error page. +/// * For JSON responses, gets treated like a normal error. +pub enum AppError { + NotFound(Backtrace), + Unauthorized(Backtrace), + Invalid(Backtrace), +} + +impl AppError { + pub fn message(&self) -> &'static str { + match self { + AppError::NotFound { .. } => "Page not found.", + AppError::Unauthorized { .. } => "Unauthorized.", + AppError::Invalid { .. } => "Invalid request.", + } + } + + pub fn status(&self) -> StatusCode { + match self { + AppError::NotFound(_) => StatusCode::NOT_FOUND, + AppError::Unauthorized(_) => StatusCode::UNAUTHORIZED, + AppError::Invalid(_) => StatusCode::BAD_REQUEST, + } + } + + pub fn backtrace(&self) -> &Backtrace { + match self { + AppError::NotFound(bt) | AppError::Unauthorized(bt) | AppError::Invalid(bt) => bt, + } + } +} + +/// API-only JSON handler return type. +/// Returns either T as JSON, or {error: "message"} +pub type JsonResult = Result, JsonError>; +pub enum JsonError { + App(AppError), + Any(AnyError), +} +impl From for JsonError { + fn from(e: AppError) -> Self { + Self::App(e) + } +} +macro_rules! impl_json_from { + ( $from:ty ) => { + impl From<$from> for JsonError { + fn from(e: $from) -> Self { + Self::Any(AnyError::from(e)) + } + } + }; +} + +/// User-visible HTML handler return type. +pub type HtmlResult = Result; +pub enum HtmlError { + App(AppError), + Any(AnyError), +} +impl From for HtmlError { + fn from(e: AppError) -> Self { + Self::App(e) + } +} +macro_rules! impl_html_from { + ( $from:ty ) => { + impl From<$from> for HtmlError { + fn from(e: $from) -> Self { + Self::Any(AnyError::from(e)) + } + } + }; +} + +#[derive(Template, WebTemplate)] +#[template(path = "error.html")] +pub struct ErrorHtml { + pub user: Option, + pub title: String, + pub message: String, + pub context: Option, + pub backtrace: Option, +} + +impl IntoResponse for HtmlError { + #[rustfmt::skip] + fn into_response(self) -> Response { + if let HtmlError::Any(e) = &self { + tracing::error!("{}", e.message()); + crate::utils::sentry::report_trace(e.message().into(), e.backtrace()); + } + + #[cfg(debug_assertions)] + let backtrace = match &self { + HtmlError::App(e) => Some(format!("{:?}", e.backtrace())), + HtmlError::Any(e) => Some(format!("{:?}", e.backtrace())), + }; + #[cfg(not(debug_assertions))] + let backtrace = None; + + #[cfg(debug_assertions)] + let context = match &self { + HtmlError::App(_) => None, + HtmlError::Any(e) => Some(e.message.clone()), + }; + #[cfg(not(debug_assertions))] + let context = None; + + let (status, html) = match &self { + HtmlError::App(e) => (e.status(), ErrorHtml { + user: None, + title: "Error".into(), + message: e.message().into(), + context, + backtrace, + }), + HtmlError::Any(_) => (StatusCode::INTERNAL_SERVER_ERROR, { + let contact_to = config().email.contact_to.as_ref(); + let from = &config().email.from; + let email = contact_to.unwrap_or(from).to_string(); + let mailto = format!(r#"{email}"#); + ErrorHtml { + user: None, + title: "We encountered an unexpected error".into(), + message: format!("The team has been alerted that there is an issue. If you need assistance, please contact {mailto}."), + context, + backtrace, + } + }) + }; + (status, html).into_response() + } +} + +impl IntoResponse for JsonError { + fn into_response(self) -> Response { + if let JsonError::Any(e) = &self { + tracing::error!("{}", e.message()); + crate::utils::sentry::report_trace(e.message().into(), e.backtrace()); + } + + let message = match self { + JsonError::App(e) => e.message(), + JsonError::Any(_) => "Internal server error.", + }; + + Json(json!({"error": message.to_string()})).into_response() + } +} + +// Conversions from any and all other error types to our app error types +macro_rules! impl_from { + ( $($from:ty),* ) => { + $( + impl_html_from!($from); + impl_json_from!($from); + )* + } +} +impl_from! { + axum::extract::multipart::MultipartError, + lettre::error::Error, + lettre::transport::smtp::Error, + askama::Error, + sqlx::Error, + reqwest::Error +} +impl From for JsonError { + fn from(e: AnyError) -> Self { + Self::Any(e) + } +} +impl From for HtmlError { + fn from(e: AnyError) -> Self { + Self::Any(e) + } +} + +// Helpers +#[track_caller] +pub fn not_found() -> AppError { + AppError::NotFound(Backtrace::new()) +} +#[macro_export] +macro_rules! bail_not_found { + () => { + return Err(not_found().into()) + }; +} +pub use bail_not_found; + +#[track_caller] +pub fn unauthorized() -> AppError { + AppError::Unauthorized(Backtrace::new()) +} +#[macro_export] +macro_rules! bail_unauthorized { + () => { + return Err(unauthorized().into()) + }; +} +pub use bail_unauthorized; + +#[track_caller] +pub fn invalid() -> AppError { + AppError::Invalid(Backtrace::new()) +} +#[macro_export] +macro_rules! bail_invalid { + () => { + return Err(invalid().into()) + }; +} +pub use bail_invalid; diff --git a/src/utils/image.rs b/src/utils/image.rs new file mode 100644 index 00000000..a7f9551c --- /dev/null +++ b/src/utils/image.rs @@ -0,0 +1,46 @@ +use std::borrow::Cow; +use std::io::Cursor; + +use axum::body::Bytes; +use image::imageops::FilterType; +use image::{DynamicImage, ImageReader}; +use jpeg_encoder::{ColorType, Encoder}; + +use crate::prelude::*; + +pub async fn decode(bytes: &Bytes) -> Result { + Ok(ImageReader::new(Cursor::new(bytes)).with_guessed_format()?.decode()?) +} + +pub async fn encode_jpeg(image: &DynamicImage, max_w: Option) -> Vec { + // Arrived experimentally by testing a variety of event flyers. + // This seems like the optimal size/quality tradeoff, with a slight pref for quality. + const QUALITY: u8 = 85; + + let mut image = Cow::Borrowed(image); + if let Some(max_width) = max_w + && image.width() > max_width + { + let ratio = max_width as f32 / image.width() as f32; + let new_height = (image.height() as f32 * ratio) as u32; + image = Cow::Owned(image.resize(max_width, new_height, FilterType::Lanczos3)); + } + + let rgb = image.to_rgb8(); + let mut bytes = vec![]; + let mut encoder = Encoder::new(&mut bytes, QUALITY); + + // Slightly smaller file sizes in exchange for slightly slower encoding performance (<100ms) + encoder.set_optimized_huffman_tables(true); + // Allows slow clients to stream in a low-res version first, and add higher quality in passes. + // For a 2.1MB image with simulated 1.44Mb/s download speed, we see an initial paint at ~1.4s + // and full detail at ~12.3s. We'd otherwise have to wait the full 12s to see the complete image. + // Note: 10 is the default used by libjpegturbo. + encoder.set_progressive(true); + encoder.set_progressive_scans(10); + + encoder + .encode(rgb.as_raw(), rgb.width() as u16, rgb.height() as u16, ColorType::Rgb) + .expect("JPEG encoding should not fail"); + bytes +} diff --git a/src/utils/mailer.rs b/src/utils/mailer.rs new file mode 100644 index 00000000..86bbe947 --- /dev/null +++ b/src/utils/mailer.rs @@ -0,0 +1,163 @@ +use lettre::message::{Mailbox, MessageBuilder}; +use lettre::transport::smtp::authentication::Credentials; +use lettre::{Message, SmtpTransport, Transport}; +use tokio::sync::{Notify, broadcast}; +use tokio::task::JoinHandle; +use tokio_stream::wrappers::BroadcastStream; +use tokio_stream::wrappers::errors::BroadcastStreamRecvError; + +use crate::EmailConfig; +use crate::db::email_queue::EmailBatch; +use crate::prelude::*; +use crate::utils::sentry; + +// DB: +// +// emails: +// + errored_at +// + batch_id +// +// email_batches: +// + id +// + status +// + size +// + errored +// + sent +// + opened +// +// email_queue: +// + batch_id +// + index + +// HTTP: +// get(/emails) -> debug view +// get(/emails/{batch_id}) -> debug view one +// +// stream(/emails) -> stream of updates to all batches (all fields of email_batches) +// stream(/emails/{batch_id}) -> stream of updates to one batch + +pub struct Mailer { + /// Wakeup the worker from its slumber + wakeup: Arc, + /// Channel streaming EmailBatch row updates + stream: broadcast::Sender, + + handle: JoinHandle<()>, +} + +impl Mailer { + pub async fn send(&self, db: &Db, batch: &EmailBatch) -> Result<()> { + batch.enqueue_back(db).await?; + self.wakeup.notify_waiters(); + Ok(()) + } + + pub async fn send_prioritized(&self, db: &Db, batch: &EmailBatch) -> Result<()> { + batch.enqueue_front(db).await?; + self.wakeup.notify_waiters(); + Ok(()) + } + + pub fn stream(&self) -> impl Stream { + BroadcastStream::new(self.stream.subscribe()).filter_map(|res| async move { + match res { + Ok(row) => Some(row), + Err(BroadcastStreamRecvError::Lagged(_)) => None, + } + }) + } +} + +impl Mailer { + pub async fn new(config: EmailConfig, db: Db) -> Result { + // `lettre` requires a default provider to be installed to use SMTPS. + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let mut transport = SmtpTransport::from_url(&config.smtp_addr)?; + if let (Some(username), Some(password)) = (config.smtp_username, config.smtp_password) { + transport = transport.credentials(Credentials::new(username, password)); + } + let transport = transport.build(); + + let wakeup = Arc::new(Notify::new()); + let (stream, _) = broadcast::channel(64); + let worker = Worker { db, transport, wakeup: wakeup.clone(), stream: stream.clone() }; + + let handle = tokio::task::spawn(worker.run()); + + Ok(Self { wakeup, stream, handle }) + } +} + +struct Worker { + db: Db, + transport: SmtpTransport, + wakeup: Arc, + stream: broadcast::Sender, +} + +impl Worker { + pub async fn run(self) { + let mut tick = tokio::time::interval(Duration::from_secs(60)); + loop { + // We wake up immediately on being notified of a new queued email, otherwise check every minute. + // It's important to also poll in case the server restarts, in which case we wouldn't otherwise get woken up. + tokio::select! { + _ = self.wakeup.notified() => {}, + _ = tick.tick() => {} + } + + loop { + match self.send_queued().await { + Ok(_) => break, + Err(e) => { + sentry::report(format!("Error while processing email queue: {}", e.message())); + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + } + } + } + + pub async fn send_queued(&self) -> Result<()> { + let delay_per_email = Duration::from_secs_f64(1.0 / config().email.ratelimit as f64); + let mut next_send_at = Instant::now(); + loop { + let email = match EmailBatch::next(&self.db).await? { + None => return Ok(()), // queue is now empty + Some(e) => e, + }; + + let now = Instant::now(); + if next_send_at > now { + tokio::time::sleep_until(next_send_at.into()).await; + } + + match self.send_one(&email).await { + Ok(_) => { + Email::mark_sent(&self.db, email.id).await?; + EmailBatch::inc_sent(&self.db, email.batch_id).await?; + } + Err(e) => { + let e = format!( + "while sending email_id={} in batch_id={}: {}", + email.id, + email.batch_id, + e.message() + ); + Email::mark_error(&self.db, email.id, &e).await?; + EmailBatch::inc_errored(&self.db, email.batch_id).await?; + sentry::report(e); + } + }; + + next_send_at += delay_per_email; + } + } + + pub async fn send_one(&self, email: &Email) -> Result<()> { + let message = email.format(&self.db).await?; + let transport = self.transport.clone(); + tokio::task::spawn_blocking(move || transport.send(&message)).await??; + Ok(()) + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 1badade9..f2e5daff 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,3 +1,12 @@ +pub mod cloudflare; pub mod config; -pub mod db; -pub mod email; +pub mod editor; +pub mod emailer; +pub mod error; +pub mod image; +pub mod routing; +pub mod sentry; +pub mod stripe; +pub mod templates; +pub mod tracing; +pub mod types; diff --git a/src/utils/routing.rs b/src/utils/routing.rs new file mode 100644 index 00000000..7b8234b3 --- /dev/null +++ b/src/utils/routing.rs @@ -0,0 +1,45 @@ +use crate::prelude::*; + +pub type AxumRouter = axum::Router; + +/// A wrapper around the axum router and the shared state, with some additional helpers. +pub struct AppRouter { + router: AxumRouter, + state: SharedAppState, +} + +impl AppRouter { + /// Create a new empty `AppRouter`. + pub fn new(state: &SharedAppState) -> Self { + Self { router: Default::default(), state: Arc::clone(state) } + } + + pub fn finish(self) -> (AxumRouter, SharedAppState) { + (self.router, self.state) + } + + /// Add some public routes. + pub fn public_routes(mut self, func: impl FnOnce(AxumRouter) -> AxumRouter) -> Self { + let subrouter = func(AxumRouter::new()); + self.router = self.router.merge(subrouter); + self + } + + /// Add some routes which require authorization and a specific role. + pub fn restricted_routes( + mut self, role: &'static str, func: impl FnOnce(AxumRouter) -> AxumRouter, + ) -> Self { + let subrouter = func(AxumRouter::new()); + let subrouter = subrouter.route_layer(axum::middleware::from_fn_with_state( + self.state.clone(), + move |user: User, req: Request, next: Next| async move { + if !user.has_role(role) { + return Err(Redirect::to(&format!("/login?redirect={}", req.uri().path()))); + } + Ok(next.run(req).await) + }, + )); + self.router = self.router.merge(subrouter); + self + } +} diff --git a/src/utils/sentry.rs b/src/utils/sentry.rs new file mode 100644 index 00000000..c632b2a5 --- /dev/null +++ b/src/utils/sentry.rs @@ -0,0 +1,55 @@ +use backtrace::Backtrace; +use sentry::Level; +use sentry::integrations::backtrace::backtrace_to_stacktrace; +use sentry::integrations::tracing::EventFilter; +use sentry::protocol::Event; +use tracing::Subscriber; +use tracing_subscriber::Layer; +use tracing_subscriber::registry::LookupSpan; + +use crate::utils::config::SentryConfig; + +pub fn init(config: &SentryConfig) { + let guard = sentry::init(( + config.dsn.as_str(), + sentry::ClientOptions { + release: sentry::release_name!(), + // Send request payloads, headers, etc. + send_default_pii: true, + ..Default::default() + }, + )); + std::mem::forget(guard); + + // static OnceCell< +} + +pub fn layer() -> impl Layer +where + S: Subscriber + for<'a> LookupSpan<'a>, +{ + sentry::integrations::tracing::layer() + .event_filter(|e| match *e.level() { + tracing::Level::ERROR | tracing::Level::WARN => EventFilter::Event, + _ => EventFilter::Ignore, + }) + .span_filter(|e| matches!(*e.level(), tracing::Level::ERROR | tracing::Level::WARN)) +} + +pub fn report(message: String) { + sentry::capture_event(Event { level: Level::Error, message: Some(message), ..Default::default() }); +} + +pub fn report_trace(message: String, backtrace: &Backtrace) { + let stacktrace = backtrace_to_stacktrace(backtrace); + + let mut event = Event { level: Level::Error, message: Some(message.clone()), ..Default::default() }; + event.exception.values = vec![sentry::protocol::Exception { + ty: "Error".into(), + value: Some(message), + stacktrace, + ..Default::default() + }]; + + sentry::capture_event(event); +} diff --git a/src/utils/stripe.rs b/src/utils/stripe.rs new file mode 100644 index 00000000..b2c32d42 --- /dev/null +++ b/src/utils/stripe.rs @@ -0,0 +1,112 @@ +use crate::db::rsvp_session::RsvpSession; +use crate::prelude::*; + +const API_VERSION: &str = "2025-07-30.basil"; + +pub struct Stripe { + app_url: String, + secret_key: String, + http: reqwest::Client, +} + +#[derive(Debug)] +pub struct LineItem { + /// Item name. + pub name: String, + /// Number of this item to purchase. + pub quantity: i64, + /// Item unit price in dollars. + pub price: i64, +} + +impl Stripe { + pub fn new(config: &Config) -> Self { + Self { + app_url: config.app.url.clone(), + secret_key: config.stripe.secret_key.clone(), + http: reqwest::Client::new(), + } + } + + /// Begin a stripe transaction, returning the client secret. + pub async fn create_session( + &self, session_id: i64, email: &str, line_items: Vec, return_path: String, + ) -> Result { + let return_url = format!("{}{}", self.app_url, return_path); + + // Log line_items for debugging before we consume them + let line_items_debug = format!("{:?}", &line_items); + + // Gross but there doesn't seem to be any other supported way to build form data in the way + // that stripe expects in particular for lists of objects. + // + // The v2 APIs will allow sending JSON data but currently checkout API doesn't support v2 + // as of 2025-06-10. + // + // See https://docs.stripe.com/api/checkout/sessions/create?api-version=2025-05-28.basil + // + let expires_at = Utc::now().timestamp() + (RsvpSession::STRIPE_EXPIRY_MINUTES * 60); + let mut form_data = format!( + "client_reference_id={session_id}\ + &customer_email={email}\ + &ui_mode=custom\ + &mode=payment\ + ¤cy=usd\ + &expires_at={expires_at}\ + &allow_promotion_codes=false\ + &payment_method_types[]=card\ + &return_url={return_url}" + ); + + for (i, LineItem { name, quantity, price }) in line_items.into_iter().enumerate() { + let price_cents = price * 100; + write!( + &mut form_data, + "&line_items[{i}][quantity]={quantity}\ + &line_items[{i}][price_data][currency]=usd\ + &line_items[{i}][price_data][unit_amount]={price_cents}\ + &line_items[{i}][price_data][product_data][name]={name}" + ) + .unwrap(); // write!() to a String can't fail + } + + #[derive(serde::Deserialize)] + struct Response { + client_secret: Option, + error: Option, + } + + #[derive(serde::Deserialize)] + struct StripeError { + message: String, + #[serde(rename = "type")] + error_type: String, + } + + #[rustfmt::skip] + let res: Response = self.http + .post("https://api.stripe.com/v1/checkout/sessions") + .header("Stripe-Version", API_VERSION) + .header(header::AUTHORIZATION, format!("Bearer {}", &self.secret_key)) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(form_data) + .send().await?.json().await?; + + if let Some(err) = res.error { + let msg = format!( + "Stripe::create_session(): {} (type={}), session_id={session_id}, email={email}, line_items={line_items_debug}", + err.message, err.error_type + ); + crate::utils::sentry::report(msg.clone()); + bail!(msg); + } + + res.client_secret.ok_or_else(|| { + let msg = format!( + "Stripe::create_session(): response missing client_secret, session_id={session_id}, email={email}, line_items={line_items_debug}" + ); + crate::utils::sentry::report(msg.clone()); + any!(msg) + }) + } +} diff --git a/src/utils/templates.rs b/src/utils/templates.rs new file mode 100644 index 00000000..b86f0710 --- /dev/null +++ b/src/utils/templates.rs @@ -0,0 +1,94 @@ +use chrono::NaiveDateTime; + +use crate::prelude::*; + +/// Askama implicitly looks for a `filters` module to be in the same scope as +/// the `#[derive(Template)]` to provide extra functions to templates. +/// +/// We export this along with `Template` in `crate::prelude`, so it should always properly be in scope. +pub mod filters { + use std::fmt::Display; + + use super::*; + + /// Check if a user is logged in and has a role. + pub fn has_role(user: &Option, role: &str) -> Result { + Ok(user.as_ref().is_some_and(|u| u.has_role(role))) + } + + /// Check if a user is logged in and has a staff role. + pub fn has_staff_role(user: &Option) -> Result { + Ok(user.as_ref().is_some_and(|u| u.has_staff_role())) + } + + /// Format a datetime with a `strftime` format string. + pub fn format_datetime(dt: &NaiveDateTime, format: &str) -> Result { + let tz = config().app.tz; + + let fmt = dt.and_utc().with_timezone(&tz).format(format); + Ok(fmt.to_string()) + } + + /// Format an optional datetime with a `strftime` format string where None maps to the empty string. + /// Useful for `` where everything is a string and "" is null. + pub fn format_optional_datetime( + dt: &Option, format: &str, + ) -> Result { + match dt { + Some(dt) => format_datetime(dt, format), + None => Ok("".into()), + } + } + + /// Turn an `Option` into a `String`, where None maps to the empty string. + /// Useful for `` where everything is a string and "" is null. + pub fn unwrap_or_empty(value: &Option) -> Result { + Ok(match value { + Some(v) => v.to_string(), + None => "".into(), + }) + } + + /// Returns the site domain + pub fn domain(_dummy: &str) -> Result { + Ok(config().app.domain.clone()) + } + + /// Returns the site URL + pub fn url(_dummy: &str) -> Result { + Ok(config().app.url.clone()) + } + + /// Returns the site URL + pub fn mailto(_dummy: &str) -> Result { + let email = config().email.from.email.to_string(); + Ok(format!("mailto:{email}")) + } + + pub fn opened_url(email_id: &i64) -> Result { + Ok(format!("{}/emails/{email_id}/footer.gif", config().app.url)) + } + + pub fn unsubscribe_url(email_id: &i64) -> Result { + Ok(format!("{}/emails/{email_id}/unsubscribe", config().app.url)) + } + + /// Livereload script enabled on debug builds. + /// Askama doesn't support plain global functions, so we have to take a dummy argument. + #[cfg(debug_assertions)] + pub fn livereload(_dummy: &str) -> Result { + // Parse app url, split off port, switch to HTTP + let url = &config().app.url; + let url = match url.rsplit_once(":") { + None => url, + Some((url, _port)) => url, + }; + let url = url.replace("https", "http"); + + Ok(format!(r#""#)) + } + #[cfg(not(debug_assertions))] + pub fn livereload(_dummy: &str) -> Result { + Ok("".into()) + } +} diff --git a/src/utils/tracing.rs b/src/utils/tracing.rs new file mode 100644 index 00000000..bd2c5fa1 --- /dev/null +++ b/src/utils/tracing.rs @@ -0,0 +1,44 @@ +use axum::http::Request; +use tower::ServiceBuilder; +use tower_http::ServiceBuilderExt as _; +use tower_http::request_id::{MakeRequestId, RequestId}; +use tower_http::trace::{DefaultOnResponse, MakeSpan, TraceLayer}; +use tracing::Span; +use uuid::Uuid; + +use crate::prelude::*; + +#[derive(Clone, Copy)] +pub struct MakeRequestUuidV7; +impl MakeRequestId for MakeRequestUuidV7 { + fn make_request_id(&mut self, _request: &Request) -> Option { + // Use UUIDv7 so that request ID can be sorted by time + let request_id = Uuid::now_v7(); + Some(RequestId::new(request_id.to_string().parse().unwrap())) + } +} + +#[derive(Clone, Copy)] +pub struct LoggingMakeSpan; +impl MakeSpan for LoggingMakeSpan { + fn make_span(&mut self, request: &Request) -> Span { + let method = request.method(); + let path = request.uri().path(); + tracing::info!("{method} {path:?}"); + tracing::span!(tracing::Level::DEBUG, "request", %method, %path) + } +} + +/// Register tracing-related middleware into the router. +pub fn add_middleware(router: AxumRouter) -> AxumRouter { + router.layer( + ServiceBuilder::new() + .set_x_request_id(MakeRequestUuidV7) + .layer( + TraceLayer::new_for_http() + .make_span_with(LoggingMakeSpan) + .on_response(DefaultOnResponse::new()), + ) + .propagate_x_request_id(), + ) +} diff --git a/src/utils/types.rs b/src/utils/types.rs new file mode 100644 index 00000000..de7bd093 --- /dev/null +++ b/src/utils/types.rs @@ -0,0 +1,5 @@ +use std::sync::Arc; + +/// The global shared application state. +pub use crate::app::AppState; +pub type SharedAppState = Arc; diff --git a/templates/home.tera.html b/templates/home.tera.html deleted file mode 100644 index cb9616c0..00000000 --- a/templates/home.tera.html +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - WLSD - - - -
-

{{ message }}

- {% if user %} -

Welcome, {{ user.first_name }} {{ user.last_name }}

- {% else %} -
- - - -
- {% endif %} -
- - - diff --git a/templates/register.tera.html b/templates/register.tera.html deleted file mode 100644 index c9661e31..00000000 --- a/templates/register.tera.html +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - WLSD - - - -
-

Register

-
- - - - - - - - -
-
- - diff --git a/templates/wlsd.tera copy.html b/templates/wlsd.tera copy.html deleted file mode 100644 index b5fa688b..00000000 --- a/templates/wlsd.tera copy.html +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - WLSD - - - -
-

{{ message }}

-
- - -