commit 434937961ca3766c90bb54303d25c02cf151f1bb Author: Patrick Britton Date: Sun Feb 15 00:33:24 2026 -0600 security: sanitized baseline for public release diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..33647dc --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,52 @@ +{ + "permissions": { + "allow": [ + "Bash(mkdir:*)", + "Bash(supabase login:*)", + "Bash(npm install:*)", + "Bash(curl:*)", + "Bash(flutter --version:*)", + "Bash(flutter create:*)", + "Bash(flutter pub get:*)", + "Bash(flutter analyze:*)", + "Bash(npx supabase db execute:*)", + "Bash(supabase status:*)", + "Bash(dir:*)", + "Bash(powershell -ExecutionPolicy Bypass -File test_edge_functions.ps1)", + "Bash(dart run:*)", + "Bash(flutter devices:*)", + "Bash(flutter uninstall:*)", + "Bash(adb:*)", + "Bash(flutter install:*)", + "Bash(powershell -ExecutionPolicy Bypass -File run_dev.ps1)", + "Bash(powershell:*)", + "Bash(timeout 5 cat:*)", + "Bash(supabase:*)", + "Bash(findstr:*)", + "Bash(npx supabase:*)", + "Bash(head:*)", + "Bash(cat:*)", + "Bash(grep:*)", + "Bash(ls:*)", + "Bash(flutter pub add:*)", + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(flutter run:*)", + "Bash(timeout 15 tail:*)", + "Bash(nslookup:*)", + "Bash(flutter logs:*)", + "Bash(flutter pub deps:*)", + "Bash(jq:*)", + "Bash(git push:*)", + "Bash(psql:*)", + "Bash(where:*)", + "Bash(python -c:*)", + "Bash(powershell.exe:*)", + "Bash(pwsh.exe -ExecutionPolicy Bypass -File ./deploy_all_functions.ps1)", + "Bash(flutter build:*)", + "Bash(find:*)", + "Bash(flutter upgrade:*)", + "Bash(xargs:*)" + ] + } +} diff --git a/.firebaserc b/.firebaserc new file mode 100644 index 0000000..5a98f50 --- /dev/null +++ b/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "your-firebase-project-id" + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eeb5232 --- /dev/null +++ b/.gitignore @@ -0,0 +1,165 @@ +# Environment variables +.env +.env.* +!.env.example +*.env +*.tfvars +*.tfvars.json + +# SSH & Keys +*.pem +*.key +id_rsa* +*.pub +ssh_config +authorized_keys +*.p12 +*.pfx +*.jks +*.keystore +*.cer +*.crt +*.der +*.gpg +*.pgp +*.asc + +# Platform / Auth Secrets +firebase-auth-*.json +*service-account*.json +go-backend/firebase-service-account.json +google-services.json +GoogleService-Info.plist +*credentials.json +account_key.json +*secret*.json +*config*.json.bak +*.p8 + + +# Supabase +.branches +.temp +supabase/.temp/ +supabase/functions/**/.env + +# OS +.DS_Store +Thumbs.db +desktop.ini + +# IDE +.vscode/* +!.vscode/extensions.json +!.vscode/launch.json +!.vscode/tasks.json +.idea/ +*.swp +*.swo +*~ +*.iml +*.iws +*.ipr + +# Large build artifacts and debug files +*.zip +*.tar.gz +*.tar +*.gz +*.exe +*.bin +*.db +*.sqlite +*.sqlite3 +*.iso + +# HAR files & Logs +*.har +localhost.har +localhost.txt +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Dependencies +node_modules/ +.pnp +.pnp.js +.yarn/cache/ +.yarn/unplugged/ +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# Testing +coverage/ +.nyc_output +.shippable + +# Build +dist/ +build/ +out/ +bin/ +obj/ +release/ +debug/ + +# Flutter specific +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ +/sojorn_app/build/ +*.g.dart +*.freezed.dart + +# Service account credentials +.env_files/ + +# Project Specific Exclusions +logo.ai +sojorn_app/analysis_results_final.txt +go-backend/.env +go-backend/bin/ +go-backend/sojorn-api* +go-backend/*linux +go-backend/seeder* +go-backend/migration* +go-backend/verify* +go-backend/migrate* +go-backend/fixdb* +go-backend/api.exe +go-backend/scripts/run_migration.go +temp_server.env +*.txt.bak + +# Non-public staging area (kept local only) +_private/ + +# Miscellaneous Security +*.history +*.bash_history +*.zsh_history +*.mysql_history +*.psql_history +*.sqlite_history +.netrc +.shittiest_secrets +.vault +*.kdb +*.kdbx +*.sops +.node_repl_history +.python_history +.bash_profile +.bashrc +.zshrc +.profile + +sojorn_docs/SOJORN_ARCHITECTURE.md diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..16f6338 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "rooveterinaryinc.roo-cline" + ] +} \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..8ce0a85 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,55 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "sojorn_app", + "cwd": "sojorn_app", + "request": "launch", + "type": "dart", + "args": [ + "--dart-define=SUPABASE_URL=https://zwkihedetedlatyvplyz.supabase.co", + "--dart-define=SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inp3a2loZWRldGVkbHl6Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njc2Nzk3OTUsImV4cCI6MjA4MzI1NTc5NX0.7YyYOABjm7cpKa1DiefkI9bH8r6SICJ89nDK9sgUa0M", + "--dart-define=API_BASE_URL=https://zwkihedetedlatyvplyz.supabase.co/functions/v1", + "--dart-define=SUPABASE_PUBLISHABLE_KEY=sb_publishable_EnvwO9qKrsm35YmfZ7ghWA_liH7fcYX", + "--dart-define=SUPABASE_SECRET_KEY=sb_secret_s3Nyt27pcqk1P80974sxOw_cqQSexYU", + "--dart-define=SUPABASE_JWT_KID=b66bc58d-34b8-481d-9eb9-8f6e3b90714e", + "--dart-define=SUPABASE_JWKS_URI=https://zwkihedetedlatyvplyz.supabase.co/auth/v1/.well-known/jwks.json" + ] + }, + { + "name": "sojorn_app (profile mode)", + "cwd": "sojorn_app", + "request": "launch", + "type": "dart", + "flutterMode": "profile", + "args": [ + "--dart-define=SUPABASE_URL=https://zwkihedetedlatyvplyz.supabase.co", + "--dart-define=SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inp3a2loZWRldGVkbHl6Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njc2Nzk3OTUsImV4cCI6MjA4MzI1NTc5NX0.7YyYOABjm7cpKa1DiefkI9bH8r6SICJ89nDK9sgUa0M", + "--dart-define=API_BASE_URL=https://zwkihedetedlatyvplyz.supabase.co/functions/v1", + "--dart-define=SUPABASE_PUBLISHABLE_KEY=sb_publishable_EnvwO9qKrsm35YmfZ7ghWA_liH7fcYX", + "--dart-define=SUPABASE_SECRET_KEY=sb_secret_s3Nyt27pcqk1P80974sxOw_cqQSexYU", + "--dart-define=SUPABASE_JWT_KID=b66bc58d-34b8-481d-9eb9-8f6e3b90714e", + "--dart-define=SUPABASE_JWKS_URI=https://zwkihedetedlatyvplyz.supabase.co/auth/v1/.well-known/jwks.json" + ] + }, + { + "name": "sojorn_app (release mode)", + "cwd": "sojorn_app", + "request": "launch", + "type": "dart", + "flutterMode": "release", + "args": [ + "--dart-define=SUPABASE_URL=https://zwkihedetedlatyvplyz.supabase.co", + "--dart-define=SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inp3a2loZWRldGVkbHl6Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njc2Nzk3OTUsImV4cCI6MjA4MzI1NTc5NX0.7YyYOABjm7cpKa1DiefkI9bH8r6SICJ89nDK9sgUa0M", + "--dart-define=API_BASE_URL=https://zwkihedetedlatyvplyz.supabase.co/functions/v1", + "--dart-define=SUPABASE_PUBLISHABLE_KEY=sb_publishable_EnvwO9qKrsm35YmfZ7ghWA_liH7fcYX", + "--dart-define=SUPABASE_SECRET_KEY=sb_secret_s3Nyt27pcqk1P80974sxOw_cqQSexYU", + "--dart-define=SUPABASE_JWT_KID=b66bc58d-34b8-481d-9eb9-8f6e3b90714e", + "--dart-define=SUPABASE_JWKS_URI=https://zwkihedetedlatyvplyz.supabase.co/auth/v1/.well-known/jwks.json" + ] + } + ] +} diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..59ca471 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,3 @@ +api.sojorn.net { + reverse_proxy localhost:8080 +} diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..0565d20 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,52 @@ +# Business Source License 1.1 + +**License text copyright © 2017 MariaDB Corporation Ab, All Rights Reserved.** +**"Business Source License" is a trademark of MariaDB Corporation Ab.** + +--- + +## Parameters + +| Parameter | Value | +|---|---| +| **Licensor** | MPLS LLC | +| **Licensed Work** | Sojorn — all Go backend, Flutter application, AI gateway, and proprietary algorithms contained in this repository. Copyright © 2025–2026 MPLS LLC. | +| **Additional Use Grant** | You may use the Licensed Work for personal and non-commercial purposes without restriction. You may use the Licensed Work for commercial purposes if your entity has less than $5,000,000 (USD) in annual gross revenue. Any commercial entity exceeding this revenue threshold, or any entity providing a competing social network service, must contact MPLS LLC for a separate commercial license agreement. | +| **Change Date** | February 12, 2029 | +| **Change License** | GNU General Public License v3.0 or later | + +--- + +## Notice + +The Business Source License (this document, or the "License") is not an Open Source license. However, the Licensed Work will eventually be made available under an Open Source license, as stated in this License. + +## License Text + +The Licensor hereby grants you the right to copy, modify, create derivative works, redistribute, and make non-production use of the Licensed Work. The Licensor may make an Additional Use Grant, above, permitting limited production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly available distribution of a specific version of the Licensed Work under this License, whichever comes first, the Licensor hereby grants you rights under the terms of the Change License, and the rights granted in the paragraph above terminate. + +If your use of the Licensed Work does not comply with the requirements currently in effect as described in this License, you must purchase a commercial license from the Licensor, its affiliated entities, or authorized resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works of the Licensed Work, are subject to this License. This License applies separately for each version of the Licensed Work and the Change Date may vary for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy of the Licensed Work. If you receive the Licensed Work in original or modified form from a third party, the terms and conditions set forth in this License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically terminate your rights under this License for the current and all other versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of Licensor or its affiliates (provided that you may use a trademark or logo of Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. + +--- + +## Why This License? + +We believe in transparency. We share our source code so that our users, security researchers, and the broader community can verify that we honor our privacy commitments. We chose the Business Source License because it protects the labor of a small, independent team while ensuring this code eventually becomes fully open source. + +We call this **Right Livelihood for Creators**: we share our work for your safety, but we protect it so we can remain independent and never need to sell your data to survive. + +If you are an individual, a researcher, a nonprofit, or a small business — welcome. Use this code freely. If you are a large commercial entity, we simply ask that you contact us so we can build a fair relationship. + +**Contact:** [legal@mp.ls](mailto:legal@mp.ls) diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..1791ec0 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,144 @@ +# Sojorn — Privacy & Data Sovereignty + +**Effective Date:** February 12, 2026 +**Last Updated:** February 12, 2026 +**Operator:** MPLS LLC + +--- + +## Our Philosophy: Privacy as a Sanctuary + +Profiting from surveillance is strictly against our principles. We reject the "attention economy" model entirely. + +Most social platforms treat your data as their product. They harvest your posts, your photos, your location, your relationships, and your attention — then sell access to the highest bidder. We built Sojorn to prove that a social network can exist without any of that. + +**Sojorn is a walled garden where your data is not a commodity.** We are groundskeepers of this space — not owners of what grows in it. + +--- + +## 1. Data Sovereignty + +We do not sell your data. We do not license your data. We do not provide your data to third-party analytics, advertising, or data brokerage firms. Your content is not indexed on public search engines. Sojorn is a private community designed to protect your posts and identity from the extractivist economy. + +## 2. What We Collect + +We collect only what is technically necessary to operate the Service: + +| Data | Purpose | Retention | +|---|---|---| +| **Email address** | Authentication, critical account notifications | Until account deletion | +| **Birth month & year** | Age verification (16+ requirement) | Until account deletion | +| **Display name & handle** | Profile identity within the network | Until account deletion | +| **Content you create** | Posts, comments, images, video — displayed to your chosen audience | Until you delete it | +| **Approximate location** (Beacons only) | Community safety incident reporting | Ephemeral — not stored permanently | +| **Device push tokens** | Delivering notifications you have opted into | Until account deletion or token refresh | + +We do **not** collect: + +- Precise GPS location outside of Beacons +- Contact lists or phone books +- Browsing history outside of Sojorn +- Biometric data +- Financial information + +## 3. Third-Party Services + +| Service | Purpose | Data Shared | +|---|---|---| +| **Firebase** | Authentication, push notifications | Email, device token | +| **Cloudflare R2** | Media file storage (images, video) | Uploaded media files | +| **SendPulse** | Newsletter delivery (opt-in only) | Email address | +| **OpenAI / Google Vision** | Content moderation (hate speech, violence detection) | Text snippets and image URLs of public posts only | + +We do **not** use third-party tracking pixels, cross-site cookies, behavioral analytics, or advertising SDKs. + +### AI Moderation Disclosure + +Public posts may be analyzed by AI moderation systems to detect policy violations (hate speech, violence, spam, NSFW content). This analysis: + +- Is performed only on content you post publicly or within groups. +- Does **not** apply to end-to-end encrypted messages or capsule content. +- Does **not** train AI models on your content — we use pre-trained safety classifiers only. +- Is subject to human review before permanent moderation action. +- Produces an audit trail visible to administrators for accountability. + +## 4. Zero-Knowledge Encryption + +Private messages and encrypted capsule content are protected by end-to-end encryption (E2EE) using keys generated on your device. Your encryption keys are wrapped with a passphrase only you know and stored as an opaque encrypted blob on our servers. **We cannot decrypt your private content.** We cannot comply with requests to produce content we cannot read. + +## 5. Your Right to Vanish + +You have the absolute right to delete your account and all associated data at any time. + +When you delete content or your account, we perform **hard deletes**: + +- Database records are permanently removed (not soft-deleted). +- Media files (images, video) are permanently removed from storage buckets. +- Encryption key backups are permanently removed. +- We do not retain shadow copies, hidden archives, or behavioral profiles. + +When you leave, you leave. + +## 6. Anti-Extraction Commitment + +MPLS LLC will never: + +- Use your content to train artificial intelligence or machine learning models. +- Sell, license, or share your content with data brokers or advertisers. +- Build advertising or behavioral profiles from your activity. +- Provide "data partnerships" or "audience insights" products derived from your content. + +## 7. Right to Livelihood + +If MPLS LLC ever wishes to feature your content in promotional materials outside of the Sojorn app interface, we must contact you directly, offer financial compensation, and receive your explicit written consent. See Section 4.5 of our [Terms of Service](https://sojorn.net/terms) for full details. + +## 8. Anti-Scraping + +We actively defend against unauthorized commercial harvesting of user content through rate limiting, authentication requirements, and automated abuse detection. Unauthorized scraping of Sojorn content is a violation of these Terms and may be pursued under the Computer Fraud and Abuse Act (CFAA). + +## 9. Law Enforcement + +We will comply with valid legal process (court orders, subpoenas) as required by law. However: + +- We will notify affected users unless legally prohibited from doing so. +- We cannot produce end-to-end encrypted content (we do not have the keys). +- We will challenge overbroad or legally deficient requests. +- We will publish a transparency report annually documenting any government data requests received. + +## 10. Children's Privacy + +Sojorn is not intended for users under 16. We do not knowingly collect data from children. If we discover that a user is under 16, we will delete their account and all associated data. + +## 11. International Users + +Sojorn is operated by MPLS LLC from the United States. If you are accessing the Service from the European Union, your data is processed in the United States. We apply the same privacy protections to all users regardless of jurisdiction. + +## 12. Changes to This Policy + +We will notify registered users via email and in-app notification of any material changes to this Privacy Policy at least 30 days before they take effect. + +## 13. Contact + +For privacy concerns: [privacy@sojorn.net](mailto:privacy@sojorn.net) +For legal inquiries: [legal@mp.ls](mailto:legal@mp.ls) + +--- + +## Why We Chose This Model + +### Right Livelihood for Creators + +Our source code is published under the [Business Source License 1.1](./LICENSE.md). We share our code so that users, security researchers, and the public can verify that we honor every commitment in this document. We chose this license because it protects the labor of a small, independent team while ensuring the code eventually becomes fully open source (GPLv3 after February 12, 2029). + +We call this **Right Livelihood for Creators** — we share our work for your safety, but we protect it so we can remain independent and never need to monetize your attention or your data. + +### Privacy as a Sanctuary + +Every technical decision we make is measured against a simple question: *Does this protect or erode the sanctuary?* + +- We chose E2EE for private messages — because a sanctuary has walls. +- We chose hard deletes — because a sanctuary does not hoard what you discard. +- We chose AI moderation with human review — because a sanctuary has guardians, not surveillance cameras. +- We chose no advertising SDK — because a sanctuary is not a billboard. + +**MPLS LLC — Groundskeepers, not owners.** diff --git a/SVG/Artboard 4.svg b/SVG/Artboard 4.svg new file mode 100644 index 0000000..52f589d --- /dev/null +++ b/SVG/Artboard 4.svg @@ -0,0 +1,13 @@ + + + + + + + + \ No newline at end of file diff --git a/TERMS.md b/TERMS.md new file mode 100644 index 0000000..9fdbd1f --- /dev/null +++ b/TERMS.md @@ -0,0 +1,101 @@ +# Sojorn — Terms of Service + +**Effective Date:** February 12, 2026 +**Last Updated:** February 12, 2026 +**Operator:** MPLS LLC + +--- + +## 1. The Agreement + +By accessing Sojorn ("the Service"), you acknowledge that you are entering a space dedicated to respect, safety, and progressive action. These Terms of Service ("Terms") constitute a binding agreement between you and MPLS LLC ("we," "us," "our"). We prioritize the safety of our community above all else. + +## 2. Zero Tolerance Policy + +We do not tolerate intolerance. Hate speech, racism, sexism, homophobia, transphobia, ableism, and fascist ideologies are strictly prohibited. Violations will result in immediate and permanent account suspension. + +## 3. No Misinformation + +We reject the spread of verifiable falsehoods, conspiracy theories, and coordinated disinformation campaigns. Posting content designed to deceive or manipulate will result in account termination. + +## 4. Content Ownership and Sanctuary + +This section replaces the broad content licenses found in conventional social media Terms of Service. We believe your work belongs to you — always. + +### 4.1 Ownership + +**You retain 100% copyright and all intellectual property rights to every piece of content you create on Sojorn.** We claim no ownership over your words, images, audio, video, or any other creative work. + +### 4.2 Limited Technical License + +By posting content, you grant MPLS LLC a **non-exclusive, royalty-free, worldwide license solely for the technical purpose of hosting, displaying, and transmitting your content** to the audience you designate within Sojorn. This license exists only so that our servers can store and deliver your content as the software requires. + +### 4.3 Immediate Revocation + +This technical license **expires immediately and irrevocably upon deletion** of the content by you. When you delete a post, comment, image, or any other content, we will remove it from our active systems and storage buckets. We do not retain shadow copies, hidden archives, or "soft-deleted" records of your content. + +### 4.4 Anti-Extraction Covenant + +MPLS LLC **will never**: + +- Use your content to train artificial intelligence or machine learning models. +- Sell, license, or provide your content to third-party data brokers, advertisers, or analytics firms. +- Mine your content for advertising profiles or behavioral targeting. +- Index your content on public search engines without your explicit opt-in. + +### 4.5 Right to Livelihood + +If MPLS LLC ever wishes to use your content for promotional purposes outside the Sojorn application interface — including but not limited to marketing materials, press releases, social media promotion, or investor presentations — we must: + +1. **Contact you directly** with a clear written description of the intended use. +2. **Offer financial compensation** for that use. +3. **Receive your explicit, written opt-in consent** before any such use. + +No blanket consent is granted by agreeing to these Terms. Each promotional use requires a separate agreement. + +## 5. Your Right to Vanish + +You have the absolute right to delete your account and all associated data at any time. When you leave, you leave. We perform hard deletes — your profile, posts, comments, media files, and metadata are permanently removed from our systems. + +## 6. End-to-End Encryption + +Private messages and encrypted capsule content are protected by end-to-end encryption (E2EE) using keys generated on your device. MPLS LLC has no ability to decrypt, read, or access this content. We cannot comply with requests to produce content we cannot read. + +## 7. AI Moderation Transparency + +We use artificial intelligence to assist with content moderation (detecting hate speech, violence, spam, and other policy violations). This AI moderation: + +- Operates only on content you post publicly or within groups. +- Does not analyze private encrypted messages (we cannot). +- Is subject to human review — AI flags are reviewed by human moderators before permanent action is taken. +- Provides a full audit trail visible to administrators for accountability. + +You may appeal any AI moderation decision through our in-app appeal process. + +## 8. Community Safety Beacons + +Sojorn includes a community safety feature ("Beacons") that allows users to report real-world safety incidents with location data. By using this feature, you consent to sharing approximate location data with other Sojorn users in your vicinity. Location data is not stored permanently and is not sold to third parties. + +## 9. Age Requirement + +You must be at least 16 years of age to use Sojorn. We collect birth month and year during registration solely to enforce this requirement. + +## 10. Liability + +MPLS LLC provides this Service "as is." We are not liable for interactions that occur between users, though we commit to active moderation to maintain community safety. We are not liable for the accuracy of community safety Beacons posted by users. + +## 11. Governing Law + +These Terms are governed by the laws of the State of Minnesota, United States. + +## 12. Changes to These Terms + +We will notify registered users via email and in-app notification of any material changes to these Terms at least 30 days before they take effect. + +## 13. Contact + +For questions about these Terms: [legal@mp.ls](mailto:legal@mp.ls) + +--- + +*MPLS LLC — Groundskeepers, not owners.* diff --git a/_legacy/supabase/apply_e2ee_migration.sql b/_legacy/supabase/apply_e2ee_migration.sql new file mode 100644 index 0000000..a793304 --- /dev/null +++ b/_legacy/supabase/apply_e2ee_migration.sql @@ -0,0 +1,103 @@ +-- ============================================================================ +-- APPLY E2EE CHAT MIGRATION MANUALLY +-- ============================================================================ +-- Run this script in your Supabase SQL Editor to apply the E2EE chat migration +-- ============================================================================ + +-- ============================================================================ +-- 1. Update profiles table to store identity key and registration ID +-- ============================================================================ + +-- Add Signal Protocol identity key and registration ID to profiles +ALTER TABLE profiles +ADD COLUMN IF NOT EXISTS identity_key TEXT, +ADD COLUMN IF NOT EXISTS registration_id INTEGER; + +-- ============================================================================ +-- 2. Create separate one_time_prekeys table +-- ============================================================================ + +-- Separate table for one-time pre-keys (consumed on use) +CREATE TABLE IF NOT EXISTS one_time_prekeys ( + id SERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + key_id INTEGER NOT NULL, + public_key TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + + -- Ensure unique key_id per user + UNIQUE(user_id, key_id) +); + +-- Index for efficient key consumption +CREATE INDEX IF NOT EXISTS idx_one_time_prekeys_user_id ON one_time_prekeys(user_id); + +-- ============================================================================ +-- 3. Update signal_keys table structure +-- ============================================================================ + +-- Remove one_time_prekeys from signal_keys (now separate table) +ALTER TABLE signal_keys +DROP COLUMN IF EXISTS one_time_prekeys; + +-- Add registration_id to signal_keys if not already present +ALTER TABLE signal_keys +ADD COLUMN IF NOT EXISTS registration_id INTEGER; + +-- ============================================================================ +-- 4. Update consume_one_time_prekey function +-- ============================================================================ + +-- Drop existing function if it exists (different return type) +DROP FUNCTION IF EXISTS consume_one_time_prekey(UUID); + +-- Create the new function to work with the separate table +CREATE FUNCTION consume_one_time_prekey(target_user_id UUID) +RETURNS TABLE(key_id INTEGER, public_key TEXT) AS $$ +DECLARE + selected_key_id INTEGER; + selected_public_key TEXT; +BEGIN + -- First, find the oldest key + SELECT otpk.key_id, otpk.public_key + INTO selected_key_id, selected_public_key + FROM one_time_prekeys otpk + WHERE otpk.user_id = target_user_id + ORDER BY otpk.created_at ASC + LIMIT 1; + + -- If we found a key, delete it and return it + IF selected_key_id IS NOT NULL THEN + DELETE FROM one_time_prekeys + WHERE user_id = target_user_id AND key_id = selected_key_id; + + RETURN QUERY SELECT selected_key_id, selected_public_key; + END IF; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- ============================================================================ +-- 5. Update RLS policies for one_time_prekeys +-- ============================================================================ + +-- Enable RLS +ALTER TABLE one_time_prekeys ENABLE ROW LEVEL SECURITY; + +-- Users can read their own pre-keys (for management) +CREATE POLICY one_time_prekeys_select_own ON one_time_prekeys + FOR SELECT USING (auth.uid() = user_id); + +-- Users can insert their own pre-keys +CREATE POLICY one_time_prekeys_insert_own ON one_time_prekeys + FOR INSERT WITH CHECK (auth.uid() = user_id); + +-- Users can delete their own pre-keys (when consumed) +CREATE POLICY one_time_prekeys_delete_own ON one_time_prekeys + FOR DELETE USING (auth.uid() = user_id); + +-- ============================================================================ +-- SUCCESS MESSAGE +-- ============================================================================ + +-- If you see this message, the migration completed successfully! +SELECT 'E2EE Chat Migration Applied Successfully!' as status; diff --git a/_legacy/supabase/config.toml b/_legacy/supabase/config.toml new file mode 100644 index 0000000..989dcf5 --- /dev/null +++ b/_legacy/supabase/config.toml @@ -0,0 +1,2 @@ +[functions.cleanup-expired-content] +verify_jwt = false \ No newline at end of file diff --git a/_legacy/supabase/functions/.vscode/settings.json b/_legacy/supabase/functions/.vscode/settings.json new file mode 100644 index 0000000..b77a66a --- /dev/null +++ b/_legacy/supabase/functions/.vscode/settings.json @@ -0,0 +1,20 @@ +{ + "deno.enable": true, + "deno.lint": true, + "deno.unstable": false, + "javascript.preferences.quoteStyle": "single", + "typescript.preferences.quoteStyle": "single", + "search.exclude": { + "**/node_modules": true, + "**/dist": true + }, + "typescript.validate.enable": false, + "javascript.validate.enable": false, + "files.associations": { + "*.ts": "typescript" + }, + "editor.defaultFormatter": "denoland.vscode-deno", + "[typescript]": { + "editor.defaultFormatter": "denoland.vscode-deno" + } +} diff --git a/_legacy/supabase/functions/_shared/harmony.ts b/_legacy/supabase/functions/_shared/harmony.ts new file mode 100644 index 0000000..b0e2d39 --- /dev/null +++ b/_legacy/supabase/functions/_shared/harmony.ts @@ -0,0 +1,193 @@ +/** + * Harmony Score Calculation + * + * Design intent: + * - Influence adapts; people are not judged. + * - Guidance replaces punishment. + * - Fit emerges naturally. + * + * Philosophy: + * - Score is private, decays over time, and is reversible. + * - Never bans or removes beliefs. + * - Shapes distribution width, not access. + * + * Inputs: + * - Blocks received (pattern-based, not single incidents) + * - Trusted reports (from high-harmony users) + * - Category friction (posting to sensitive categories with low CIS) + * - Posting cadence (erratic spikes vs steady participation) + * - Rewrite prompts triggered (content rejected for tone) + * - False reports made (reports that were dismissed) + * + * Effects: + * - Shapes distribution width (reach) + * - Adds gentle posting friction if low + * - Limits Trending eligibility + */ + +export interface HarmonyInputs { + user_id: string; + blocks_received_7d: number; + blocks_received_30d: number; + trusted_reports_against: number; + total_reports_against: number; + posts_rejected_7d: number; // rewrite prompts triggered + posts_created_7d: number; + false_reports_filed: number; // reports dismissed after review + validated_reports_filed: number; // reports confirmed after review + days_since_signup: number; + current_harmony_score: number; + current_tier: string; +} + +export interface HarmonyAdjustment { + new_score: number; + delta: number; + reason: string; + new_tier: string; +} + +/** + * Calculate harmony score adjustment based on recent behavior + */ +export function calculateHarmonyAdjustment(inputs: HarmonyInputs): HarmonyAdjustment { + let delta = 0; + const reasons: string[] = []; + + // 1. Blocks received (pattern-based) + // Single block = minor signal. Pattern of blocks = strong negative signal. + if (inputs.blocks_received_7d >= 3) { + delta -= 10; + reasons.push('Multiple blocks received recently'); + } else if (inputs.blocks_received_30d >= 5) { + delta -= 5; + reasons.push('Pattern of blocks over time'); + } + + // 2. Trusted reports + // Reports from high-harmony users are strong negative signals + if (inputs.trusted_reports_against >= 2) { + delta -= 8; + reasons.push('Multiple reports from trusted users'); + } else if (inputs.trusted_reports_against === 1) { + delta -= 3; + reasons.push('Report from trusted user'); + } + + // 3. Content rejection rate (rewrite prompts) + // High rejection rate indicates persistent tone issues + const rejectionRate = + inputs.posts_created_7d > 0 ? inputs.posts_rejected_7d / inputs.posts_created_7d : 0; + + if (rejectionRate > 0.3) { + delta -= 6; + reasons.push('High content rejection rate'); + } else if (rejectionRate > 0.1) { + delta -= 2; + reasons.push('Some content rejected for tone'); + } + + // 4. False reports filed + // Filing false reports is harmful behavior + if (inputs.false_reports_filed >= 3) { + delta -= 7; + reasons.push('Multiple false reports filed'); + } else if (inputs.false_reports_filed >= 1) { + delta -= 3; + reasons.push('False report filed'); + } + + // 5. Positive signals: Validated reports + // Accurate reporting helps the community + if (inputs.validated_reports_filed >= 3) { + delta += 5; + reasons.push('Helpful reporting behavior'); + } else if (inputs.validated_reports_filed >= 1) { + delta += 2; + reasons.push('Validated report filed'); + } + + // 6. Time-based trust growth + // Steady participation without issues slowly builds trust + if (inputs.days_since_signup > 90 && delta >= 0) { + delta += 2; + reasons.push('Sustained positive participation'); + } else if (inputs.days_since_signup > 30 && delta >= 0) { + delta += 1; + reasons.push('Consistent participation'); + } + + // 7. Natural decay toward equilibrium (50) + // Scores gradually drift back toward 50 over time + // This ensures old negative signals fade + if (inputs.current_harmony_score < 45) { + delta += 1; + reasons.push('Natural recovery over time'); + } else if (inputs.current_harmony_score > 60) { + delta -= 1; + reasons.push('Natural equilibrium adjustment'); + } + + // 8. Calculate new score with bounds [0, 100] + const new_score = Math.max(0, Math.min(100, inputs.current_harmony_score + delta)); + + // 9. Determine tier based on new score + let new_tier: string; + if (new_score >= 75) { + new_tier = 'established'; + } else if (new_score >= 50) { + new_tier = 'trusted'; + } else if (new_score >= 25) { + new_tier = 'new'; + } else { + new_tier = 'restricted'; + } + + return { + new_score, + delta, + reason: reasons.join('; ') || 'No significant changes', + new_tier, + }; +} + +/** + * Get user-facing explanation of harmony score (without revealing the number) + */ +export function getHarmonyExplanation(tier: string, score: number): string { + if (tier === 'restricted') { + return 'Your posts currently have limited reach. This happens when content patterns trigger community concerns. Your reach will naturally restore over time with calm participation.'; + } + + if (tier === 'new') { + return 'Your posts reach a modest audience while you build trust. Steady participation and helpful contributions will gradually expand your reach.'; + } + + if (tier === 'trusted') { + return 'Your posts reach a good audience. You have shown consistent, calm participation.'; + } + + if (tier === 'established') { + return 'Your posts reach a wide audience. You have built strong trust through sustained positive contributions.'; + } + + return 'Your reach is determined by your participation patterns and community response.'; +} + +/** + * Determine reach multiplier for feed algorithms + */ +export function getReachMultiplier(tier: string, score: number): number { + const baseMultiplier: Record = { + restricted: 0.2, + new: 0.6, + trusted: 1.0, + established: 1.4, + }; + + // Fine-tune based on score within tier + const tierBase = baseMultiplier[tier] || 1.0; + const scoreAdjustment = (score - 50) / 200; // -0.25 to +0.25 + + return Math.max(0.1, tierBase + scoreAdjustment); +} diff --git a/_legacy/supabase/functions/_shared/r2_signer.ts b/_legacy/supabase/functions/_shared/r2_signer.ts new file mode 100644 index 0000000..ed820ce --- /dev/null +++ b/_legacy/supabase/functions/_shared/r2_signer.ts @@ -0,0 +1,108 @@ +import { AwsClient } from 'https://esm.sh/aws4fetch@1.0.17' + +const CUSTOM_MEDIA_DOMAIN = (Deno.env.get("CUSTOM_MEDIA_DOMAIN") ?? "https://img.sojorn.net").trim(); +const CUSTOM_VIDEO_DOMAIN = (Deno.env.get("CUSTOM_VIDEO_DOMAIN") ?? "https://quips.sojorn.net").trim(); + +const DEFAULT_BUCKET_NAME = "sojorn-media"; +const RESOLVED_BUCKET = (Deno.env.get("R2_BUCKET_NAME") ?? DEFAULT_BUCKET_NAME).trim(); + +function normalizeKey(key: string): string { + let normalized = key.replace(/^\/+/, ""); + if (RESOLVED_BUCKET && normalized.startsWith(`${RESOLVED_BUCKET}/`)) { + normalized = normalized.slice(RESOLVED_BUCKET.length + 1); + } + return normalized; +} + +function extractObjectKey(input: string): string { + const trimmed = input.trim(); + if (!trimmed) { + throw new Error("Missing file key"); + } + + try { + const url = new URL(trimmed); + const key = decodeURIComponent(url.pathname); + return normalizeKey(key); + } catch { + return normalizeKey(trimmed); + } +} + +export function transformLegacyMediaUrl(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed) return null; + + try { + const url = new URL(trimmed); + + // Handle legacy media.sojorn.net URLs + if (url.hostname === 'media.sojorn.net') { + const key = decodeURIComponent(url.pathname); + return key; + } + + return null; + } catch { + return null; + } +} + +// Deprecated: no-op signer retained for compatibility +export async function signR2Url(fileKey: string, expiresIn: number = 3600): Promise { + return await trySignR2Url(fileKey, undefined, expiresIn) ?? fileKey; +} + +export async function trySignR2Url(fileKey: string, bucket?: string, expiresIn: number = 3600): Promise { + try { + const key = normalizeKey(extractObjectKey(fileKey)); + + // Check if we have credentials to sign. If not, fallback to public URL. + const ACCOUNT_ID = Deno.env.get('R2_ACCOUNT_ID'); + const ACCESS_KEY = Deno.env.get('R2_ACCESS_KEY'); + const SECRET_KEY = Deno.env.get('R2_SECRET_KEY'); + + const isVideo = key.toLowerCase().endsWith('.mp4') || + key.toLowerCase().endsWith('.mov') || + key.toLowerCase().endsWith('.webm') || + bucket === 'sojorn-videos'; + + if (!ACCOUNT_ID || !ACCESS_KEY || !SECRET_KEY) { + console.warn("Missing R2 credentials for signing. Falling back to public domain."); + const domain = isVideo ? CUSTOM_VIDEO_DOMAIN : CUSTOM_MEDIA_DOMAIN; + if (domain && domain.startsWith("http")) { + return `${domain.replace(/\/+$/, "")}/${key}`; + } + return fileKey; + } + + const r2 = new AwsClient({ + accessKeyId: ACCESS_KEY, + secretAccessKey: SECRET_KEY, + region: 'auto', + service: 's3', + }); + + const targetBucket = bucket || (isVideo ? 'sojorn-videos' : 'sojorn-media'); + + // We sign against the actual R2 endpoint to ensure auth works, + // but the SignedMediaImage can handle redirect/proxying if needed. + const url = new URL(`https://${ACCOUNT_ID}.r2.cloudflarestorage.com/${targetBucket}/${key}`); + + // Add expiration + url.searchParams.set('X-Amz-Expires', expiresIn.toString()); + + const signedRequest = await r2.sign(url, { + method: "GET", + aws: { signQuery: true, allHeaders: false }, + }); + + return signedRequest.url; + } catch (error) { + console.error("R2 signing failed", { + fileKey, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } +} diff --git a/_legacy/supabase/functions/_shared/ranking.ts b/_legacy/supabase/functions/_shared/ranking.ts new file mode 100644 index 0000000..5d415fe --- /dev/null +++ b/_legacy/supabase/functions/_shared/ranking.ts @@ -0,0 +1,133 @@ +/** + * Ranking Algorithm for sojorn Feed + * + * Design intent: + * - Attention moves slowly. + * - Nothing competes for dominance. + * - Clean content is protected from suppression. + * + * Principles: + * - Saves > likes (intentional curation over quick reaction) + * - Steady appreciation over time > viral spikes + * - Low block rate = content is not harmful + * - Low trusted report rate = content is genuinely clean + * - Ignore comment count (we don't reward arguments) + * - Ignore report spikes on high-CIS posts (brigading protection) + */ + +export interface PostForRanking { + id: string; + created_at: string; + cis_score: number; + tone_label: string; + save_count: number; + like_count: number; + view_count: number; + author_harmony_score: number; + author_tier: string; + blocks_received_24h: number; + trusted_reports: number; + total_reports: number; +} + +/** + * Calculate calm velocity score + * Measures steady appreciation rather than viral spikes + */ +function calculateCalmVelocity(post: PostForRanking): number { + const ageInHours = (Date.now() - new Date(post.created_at).getTime()) / (1000 * 60 * 60); + + if (ageInHours === 0 || post.view_count === 0) return 0; + + // Saves are weighted 3x more than likes + const engagementScore = post.save_count * 3 + post.like_count; + + // Engagement rate (relative to views) + const engagementRate = engagementScore / Math.max(post.view_count, 1); + + // Calm velocity = steady engagement over time (not spiky) + // Using logarithmic scaling to prevent runaway viral effects + const velocity = Math.log1p(engagementRate * 100) / Math.log1p(ageInHours + 1); + + return velocity; +} + +/** + * Calculate safety score + * Lower score = content has triggered negative signals + */ +function calculateSafetyScore(post: PostForRanking): number { + let score = 1.0; + + // Penalize if blocks received in last 24h + if (post.blocks_received_24h > 0) { + score -= post.blocks_received_24h * 0.2; + } + + // Penalize trusted reports heavily + if (post.trusted_reports > 0) { + score -= post.trusted_reports * 0.3; + } + + // Ignore report spikes if CIS is high (brigading protection) + if (post.cis_score < 0.7 && post.total_reports > 2) { + score -= 0.15; + } + + return Math.max(score, 0); +} + +/** + * Calculate author influence multiplier + * Based on harmony score and tier + */ +function calculateAuthorInfluence(post: PostForRanking): number { + const harmonyMultiplier = post.author_harmony_score / 100; // 0-1 range + + const tierMultiplier: Record = { + new: 0.5, + trusted: 1.0, + established: 1.3, + restricted: 0.2, + }; + + return harmonyMultiplier * (tierMultiplier[post.author_tier] || 1.0); +} + +/** + * Calculate final ranking score for sojorn feed + */ +export function calculateRankingScore(post: PostForRanking): number { + // Base score from content integrity + const cisBonus = post.cis_score; + + // Tone eligibility (handled in feed query, but can boost here) + const toneBonus = post.tone_label === 'positive' ? 1.2 : post.tone_label === 'neutral' ? 1.0 : 0.8; + + // Calm velocity (steady appreciation) + const velocity = calculateCalmVelocity(post); + + // Safety (no blocks or trusted reports) + const safety = calculateSafetyScore(post); + + // Author influence + const influence = calculateAuthorInfluence(post); + + // Final score + const score = cisBonus * toneBonus * velocity * safety * influence; + + return score; +} + +/** + * Rank posts for feed + * Returns sorted array with scores attached + */ +export function rankPosts(posts: PostForRanking[]): Array { + return posts + .map((post) => ({ + ...post, + rank_score: calculateRankingScore(post), + })) + .sort((a, b) => b.rank_score - a.rank_score); +} diff --git a/_legacy/supabase/functions/_shared/supabase-client.ts b/_legacy/supabase/functions/_shared/supabase-client.ts new file mode 100644 index 0000000..9cd574d --- /dev/null +++ b/_legacy/supabase/functions/_shared/supabase-client.ts @@ -0,0 +1,27 @@ +/** + * Shared Supabase client configuration for Edge Functions + */ + +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; + +export function createSupabaseClient(authHeader: string) { + return createClient( + Deno.env.get('SUPABASE_URL') ?? '', + Deno.env.get('SUPABASE_ANON_KEY') ?? '', + { + global: { + headers: { + Authorization: authHeader, + apikey: Deno.env.get('SUPABASE_ANON_KEY') ?? '', + }, + }, + } + ); +} + +export function createServiceClient() { + return createClient( + Deno.env.get('SUPABASE_URL') ?? '', + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '' + ); +} diff --git a/_legacy/supabase/functions/_shared/tone-detection.ts b/_legacy/supabase/functions/_shared/tone-detection.ts new file mode 100644 index 0000000..f12b588 --- /dev/null +++ b/_legacy/supabase/functions/_shared/tone-detection.ts @@ -0,0 +1,173 @@ +/** + * Content Filtering with OpenAI Moderation API + * + * Philosophy: + * 1. Block slurs immediately (zero tolerance) + * 2. Send to OpenAI Moderation API for additional checking + * 3. Everything else is allowed + */ + +export type ToneLabel = 'positive' | 'neutral' | 'mixed' | 'negative' | 'hostile' | 'hate'; + +export interface ToneAnalysis { + tone: ToneLabel; + cis: number; // content integrity score (0-1) + flags: string[]; // detected patterns + shouldReject: boolean; + rejectReason?: string; +} + +// Slurs - zero tolerance (block immediately) +const SLURS = [ + // Racial slurs + 'nigger', 'nigga', 'negro', 'chink', 'gook', 'spic', 'wetback', 'raghead', + 'sandnigger', 'coon', 'darkie', 'jap', 'zipperhead', 'mex', + // Homophobic slurs + 'faggot', 'fag', 'fags', 'dyke', 'tranny', 'trannie', 'homo', 'lez', 'lesbo', 'queer', + // Other + 'kike', 'spook', 'simian', 'groids', 'currymuncher', 'paki', 'cunt', +]; + +const OPENAI_MODERATION_URL = 'https://api.openai.com/v1/moderations'; + +/** + * Analyze text - first check slurs, then send to OpenAI Moderation API + */ +export async function analyzeTone(text: string): Promise { + const flags: string[] = []; + const lowerText = text.toLowerCase(); + + // Check for slurs (zero tolerance - block immediately) + const foundSlurs = SLURS.filter(slug => lowerText.includes(slug)); + if (foundSlurs.length > 0) { + return { + tone: 'hate', + cis: 0.0, + flags: foundSlurs, + shouldReject: true, + rejectReason: 'This content contains slurs which are not allowed.', + }; + } + + // Send to OpenAI Moderation API for additional checking + const openAiKey = Deno.env.get('OPEN_AI'); + console.log('OPEN_AI key exists:', !!openAiKey); + + if (openAiKey) { + try { + console.log('Sending to OpenAI Moderation API, text:', text); + + const response = await fetch(OPENAI_MODERATION_URL, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${openAiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ model: 'omni-moderation-latest', input: text }), + }); + + if (response.ok) { + const data = await response.json(); + const result = data.results[0]; + + // Check various categories (using correct OpenAI category names) + const categories = result.categories; + + if (categories['hate'] || categories['hate/threatening']) { + return { + tone: 'hate', + cis: 0.0, + flags: ['openai_hate'], + shouldReject: true, + rejectReason: 'This content was flagged by moderation.', + }; + } + + if (categories['harassment'] || categories['harassment/threatening']) { + return { + tone: 'hostile', + cis: 0.1, + flags: ['openai_harassment'], + shouldReject: true, + rejectReason: 'This content contains harassment.', + }; + } + + if (categories['sexual'] || categories['sexual/minors']) { + return { + tone: 'hostile', + cis: 0.1, + flags: ['openai_sexual'], + shouldReject: true, + rejectReason: 'This content is not appropriate.', + }; + } + + if (categories['violence'] || categories['violence/graphic']) { + return { + tone: 'hostile', + cis: 0.1, + flags: ['openai_violence'], + shouldReject: true, + rejectReason: 'This content contains violence.', + }; + } + + if (categories['self-harm'] || categories['self-harm/intent'] || categories['self-harm/instructions']) { + return { + tone: 'hostile', + cis: 0.1, + flags: ['openai_self_harm'], + shouldReject: true, + rejectReason: 'This content contains self-harm references.', + }; + } + } + } catch (e) { + console.error('OpenAI moderation error:', e); + // Continue with basic analysis if moderation API fails + } + } + + // Determine tone based on basic sentiment + const hasProfanity = /fuck|shit|damn|ass|bitch|dick|cock|pussy|cunt|hell|bastard/i.test(text); + const isPositive = /love|thank|grateful|appreciate|happy|joy|peace|calm|beautiful|wonderful|amazing|great/i.test(text); + const isNegative = /hate|angry|furious|enraged|upset|sad|depressed|hopeless|worthless|terrible/i.test(text); + + let tone: ToneLabel; + let cis: number; + + if (isPositive && !isNegative) { + tone = 'positive'; + cis = 0.9; + } else if (isNegative && !isPositive) { + tone = 'negative'; + cis = 0.5; + flags.push('negative_tone'); + } else if (hasProfanity) { + tone = 'neutral'; + cis = 0.7; + flags.push('profanity'); + } else { + tone = 'neutral'; + cis = 0.8; + } + + return { tone, cis, flags, shouldReject: false }; +} + +/** + * Generate user-facing feedback for rejected content + */ +export function getRewriteSuggestion(analysis: ToneAnalysis): string { + if (analysis.tone === 'hate') { + return 'Slurs are not allowed on sojorn.'; + } + if (analysis.tone === 'hostile') { + return 'Sharp speech does not travel here. Consider softening your words.'; + } + if (analysis.tone === 'negative') { + return 'This reads as negative. If you want it to reach others, try reframing.'; + } + return 'Consider adjusting your tone for better engagement.'; +} diff --git a/_legacy/supabase/functions/_shared/validation.ts b/_legacy/supabase/functions/_shared/validation.ts new file mode 100644 index 0000000..a84fbf3 --- /dev/null +++ b/_legacy/supabase/functions/_shared/validation.ts @@ -0,0 +1,53 @@ +/** + * Shared validation utilities + */ + +export class ValidationError extends Error { + constructor(message: string, public field?: string) { + super(message); + this.name = 'ValidationError'; + } +} + +export function validatePostBody(body: string): void { + const trimmed = body.trim(); + + if (trimmed.length === 0) { + throw new ValidationError('Post cannot be empty.', 'body'); + } + + if (body.length > 500) { + throw new ValidationError('Post is too long (max 500 characters).', 'body'); + } +} + +export function validateCommentBody(body: string): void { + const trimmed = body.trim(); + + if (trimmed.length === 0) { + throw new ValidationError('Comment cannot be empty.', 'body'); + } + + if (body.length > 300) { + throw new ValidationError('Comment is too long (max 300 characters).', 'body'); + } +} + +export function validateReportReason(reason: string): void { + const trimmed = reason.trim(); + + if (trimmed.length < 10) { + throw new ValidationError('Report reason must be at least 10 characters.', 'reason'); + } + + if (reason.length > 500) { + throw new ValidationError('Report reason is too long (max 500 characters).', 'reason'); + } +} + +export function validateUUID(value: string, fieldName: string): void { + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!uuidRegex.test(value)) { + throw new ValidationError(`Invalid ${fieldName}.`, fieldName); + } +} diff --git a/_legacy/supabase/functions/appreciate/config.toml b/_legacy/supabase/functions/appreciate/config.toml new file mode 100644 index 0000000..4d86786 --- /dev/null +++ b/_legacy/supabase/functions/appreciate/config.toml @@ -0,0 +1 @@ +verify_jwt = false diff --git a/_legacy/supabase/functions/appreciate/index.ts b/_legacy/supabase/functions/appreciate/index.ts new file mode 100644 index 0000000..d4dc985 --- /dev/null +++ b/_legacy/supabase/functions/appreciate/index.ts @@ -0,0 +1,202 @@ +/** + * POST /appreciate - Appreciate a post (boost it) + * DELETE /appreciate - Remove appreciation + * + * Design intent: + * - "Appreciate" instead of "like" - more intentional + * - Quiet appreciation matters + * - Boost-only, no downvotes + */ + +import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'; +import { createSupabaseClient, createServiceClient } from '../_shared/supabase-client.ts'; +import { validateUUID, ValidationError } from '../_shared/validation.ts'; + +const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || '*'; +const CORS_HEADERS = { + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + 'Access-Control-Allow-Methods': 'POST, DELETE', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +interface AppreciateRequest { + post_id: string; +} + +serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { headers: CORS_HEADERS }); + } + + try { + const authHeader = req.headers.get('Authorization'); + console.log('Auth header present:', !!authHeader, 'Length:', authHeader?.length ?? 0); + + if (!authHeader) { + return new Response(JSON.stringify({ error: 'Missing authorization header' }), { + status: 401, + headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, + }); + } + + const supabase = createSupabaseClient(authHeader); + const adminClient = createServiceClient(); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + console.log('Auth result - user:', user?.id ?? 'null', 'error:', authError?.message ?? 'none'); + + if (authError || !user) { + return new Response(JSON.stringify({ + error: 'Unauthorized', + message: authError?.message ?? 'No user found' + }), { + status: 401, + headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, + }); + } + + const { post_id } = (await req.json()) as AppreciateRequest; + validateUUID(post_id, 'post_id'); + + // Use admin client to check post existence - RLS was causing issues for some users + // The post_likes insert will still enforce that only valid posts can be liked + const { data: postRow, error: postError } = await adminClient + .from('posts') + .select('id, visibility, author_id, status') + .eq('id', post_id) + .maybeSingle(); + + if (postError || !postRow) { + console.error('Post lookup failed:', { post_id, error: postError?.message }); + return new Response( + JSON.stringify({ error: 'Post not found' }), + { status: 404, headers: { 'Content-Type': 'application/json' } } + ); + } + + // Check if post is active (published) + // Note: posts use 'active' status for published posts + if (postRow.status !== 'active') { + return new Response( + JSON.stringify({ error: 'Post is not available' }), + { status: 404, headers: { 'Content-Type': 'application/json' } } + ); + } + + // For private posts, verify the user has access + if (postRow.visibility === 'private' && postRow.author_id !== user.id) { + return new Response( + JSON.stringify({ error: 'Post not accessible' }), + { status: 403, headers: { 'Content-Type': 'application/json' } } + ); + } + + // For followers-only posts, verify the user follows the author + if (postRow.visibility === 'followers' && postRow.author_id !== user.id) { + const { data: followRow } = await adminClient + .from('follows') + .select('status') + .eq('follower_id', user.id) + .eq('following_id', postRow.author_id) + .eq('status', 'accepted') + .maybeSingle(); + + if (!followRow) { + return new Response( + JSON.stringify({ error: 'You must follow this user to appreciate their posts' }), + { status: 403, headers: { 'Content-Type': 'application/json' } } + ); + } + } + + // Handle remove appreciation (DELETE) + if (req.method === 'DELETE') { + const { error: deleteError } = await adminClient + .from('post_likes') + .delete() + .eq('user_id', user.id) + .eq('post_id', post_id); + + if (deleteError) { + console.error('Error removing appreciation:', deleteError); + return new Response(JSON.stringify({ error: 'Failed to remove appreciation' }), { + status: 500, + headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, + }); + } + + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, + }); + } + + // Handle appreciate (POST) + const { error: likeError } = await adminClient + .from('post_likes') + .insert({ + user_id: user.id, + post_id, + }); + + if (likeError) { + console.error('Like error details:', JSON.stringify({ + code: likeError.code, + message: likeError.message, + details: likeError.details, + hint: likeError.hint, + user_id: user.id, + post_id, + })); + + // Already appreciated (duplicate key) + if (likeError.code === '23505') { + return new Response( + JSON.stringify({ error: 'You have already appreciated this post' }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + + // Post not visible (RLS blocked it) + if (likeError.message?.includes('violates row-level security')) { + return new Response( + JSON.stringify({ error: 'Post not found or not accessible', code: likeError.code }), + { status: 404, headers: { 'Content-Type': 'application/json' } } + ); + } + + console.error('Error appreciating post:', likeError); + return new Response(JSON.stringify({ error: 'Failed to appreciate post', details: likeError.message }), { + status: 500, + headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, + }); + } + + return new Response( + JSON.stringify({ + success: true, + message: 'Appreciation noted. Quiet signals matter.', + }), + { + status: 200, + headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, + } + ); + } catch (error) { + if (error instanceof ValidationError) { + return new Response( + JSON.stringify({ error: 'Validation error', message: error.message }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + + console.error('Unexpected error:', error); + return new Response(JSON.stringify({ error: 'Internal server error' }), { + status: 500, + headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, + }); + } +}); diff --git a/_legacy/supabase/functions/block/config.toml b/_legacy/supabase/functions/block/config.toml new file mode 100644 index 0000000..4d86786 --- /dev/null +++ b/_legacy/supabase/functions/block/config.toml @@ -0,0 +1 @@ +verify_jwt = false diff --git a/_legacy/supabase/functions/block/index.ts b/_legacy/supabase/functions/block/index.ts new file mode 100644 index 0000000..1bad0e2 --- /dev/null +++ b/_legacy/supabase/functions/block/index.ts @@ -0,0 +1,183 @@ +/** + * POST /block + * + * Design intent: + * - One-tap, immediate, silent. + * - Blocking removes all visibility both ways. + * - No drama, no notification, complete separation. + * + * Flow: + * 1. Validate auth + * 2. Create block record + * 3. Remove existing follows (if any) + * 4. Log audit event + * 5. Return success + */ + +import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'; +import { createSupabaseClient, createServiceClient } from '../_shared/supabase-client.ts'; +import { validateUUID, ValidationError } from '../_shared/validation.ts'; + +interface BlockRequest { + user_id: string; // the user to block +} + +serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, DELETE', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', + }, + }); + } + + try { + // 1. Validate auth + const authHeader = req.headers.get('Authorization'); + if (!authHeader) { + return new Response(JSON.stringify({ error: 'Missing authorization header' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const supabase = createSupabaseClient(authHeader); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError || !user) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Handle unblock (DELETE method) + if (req.method === 'DELETE') { + const { user_id } = (await req.json()) as BlockRequest; + validateUUID(user_id, 'user_id'); + + const { error: deleteError } = await supabase + .from('blocks') + .delete() + .eq('blocker_id', user.id) + .eq('blocked_id', user_id); + + if (deleteError) { + console.error('Error removing block:', deleteError); + return new Response(JSON.stringify({ error: 'Failed to remove block' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const serviceClient = createServiceClient(); + await serviceClient.rpc('log_audit_event', { + p_actor_id: user.id, + p_event_type: 'user_unblocked', + p_payload: { blocked_id: user_id }, + }); + + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // 2. Parse request (POST method) + const { user_id: blocked_id } = (await req.json()) as BlockRequest; + + // 3. Validate input + validateUUID(blocked_id, 'user_id'); + + if (blocked_id === user.id) { + return new Response( + JSON.stringify({ + error: 'Invalid block', + message: 'You cannot block yourself.', + }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + // 4. Create block (idempotent - duplicate key will be ignored) + const { error: blockError } = await supabase.from('blocks').insert({ + blocker_id: user.id, + blocked_id, + }); + + if (blockError && !blockError.message.includes('duplicate')) { + console.error('Error creating block:', blockError); + return new Response(JSON.stringify({ error: 'Failed to create block' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // 5. Remove any existing follows (both directions) + // This ensures complete separation + const { error: unfollowError1 } = await supabase + .from('follows') + .delete() + .eq('follower_id', user.id) + .eq('following_id', blocked_id); + + const { error: unfollowError2 } = await supabase + .from('follows') + .delete() + .eq('follower_id', blocked_id) + .eq('following_id', user.id); + + if (unfollowError1 || unfollowError2) { + console.warn('Error removing follows during block:', unfollowError1 || unfollowError2); + // Continue anyway - block is more important + } + + // 6. Log audit event + const serviceClient = createServiceClient(); + await serviceClient.rpc('log_audit_event', { + p_actor_id: user.id, + p_event_type: 'user_blocked', + p_payload: { blocked_id }, + }); + + // 7. Return success + return new Response( + JSON.stringify({ + success: true, + message: 'Block applied. You will no longer see each other.', + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + } catch (error) { + if (error instanceof ValidationError) { + return new Response( + JSON.stringify({ + error: 'Validation error', + message: error.message, + field: error.field, + }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + console.error('Unexpected error:', error); + return new Response(JSON.stringify({ error: 'Internal server error' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +}); diff --git a/_legacy/supabase/functions/calculate-harmony/config.toml b/_legacy/supabase/functions/calculate-harmony/config.toml new file mode 100644 index 0000000..4d86786 --- /dev/null +++ b/_legacy/supabase/functions/calculate-harmony/config.toml @@ -0,0 +1 @@ +verify_jwt = false diff --git a/_legacy/supabase/functions/calculate-harmony/index.ts b/_legacy/supabase/functions/calculate-harmony/index.ts new file mode 100644 index 0000000..4f34547 --- /dev/null +++ b/_legacy/supabase/functions/calculate-harmony/index.ts @@ -0,0 +1,215 @@ +/** + * Harmony Score Calculation (Cron Job) + * + * This function runs periodically (e.g., daily) to recalculate harmony scores + * for all users based on their recent behavior patterns. + * + * Design intent: + * - Influence adapts automatically based on behavior. + * - Scores decay over time (old issues fade). + * - Changes are gradual, not sudden. + * + * Trigger: Scheduled via Supabase cron or external scheduler + */ + +import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'; +import { createServiceClient } from '../_shared/supabase-client.ts'; +import { calculateHarmonyAdjustment, type HarmonyInputs } from '../_shared/harmony.ts'; + +serve(async (req) => { + try { + // Verify this is a scheduled/cron request + const authHeader = req.headers.get('Authorization'); + const cronSecret = Deno.env.get('CRON_SECRET'); + + if (!authHeader || authHeader !== `Bearer ${cronSecret}`) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const serviceClient = createServiceClient(); + + // 1. Get all users with their current trust state + const { data: users, error: usersError } = await serviceClient + .from('trust_state') + .select('user_id, harmony_score, tier, counters') + .order('user_id'); + + if (usersError) { + console.error('Error fetching users:', usersError); + return new Response(JSON.stringify({ error: 'Failed to fetch users' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); + + let updatedCount = 0; + let errorCount = 0; + + // 2. Process each user + for (const user of users) { + try { + // Gather behavior metrics for this user + + // Blocks received + const { count: blocks7d } = await serviceClient + .from('blocks') + .select('*', { count: 'exact', head: true }) + .eq('blocked_id', user.user_id) + .gte('created_at', sevenDaysAgo); + + const { count: blocks30d } = await serviceClient + .from('blocks') + .select('*', { count: 'exact', head: true }) + .eq('blocked_id', user.user_id) + .gte('created_at', thirtyDaysAgo); + + // Reports against this user + const { data: reportsAgainst } = await serviceClient + .from('reports') + .select('reporter_id, status') + .or('target_type.eq.post,target_type.eq.comment') + .in( + 'target_id', + serviceClient + .from('posts') + .select('id') + .eq('author_id', user.user_id) + .then((r) => r.data?.map((p) => p.id) || []) + ); + + // Count trusted reports (from high-harmony reporters) + let trustedReportsCount = 0; + if (reportsAgainst) { + for (const report of reportsAgainst) { + const { data: reporterTrust } = await serviceClient + .from('trust_state') + .select('harmony_score') + .eq('user_id', report.reporter_id) + .single(); + + if (reporterTrust && reporterTrust.harmony_score >= 70) { + trustedReportsCount++; + } + } + } + + // Posts rejected in last 7 days (from audit log) + const { data: rejectedPosts } = await serviceClient + .from('audit_log') + .select('id') + .eq('actor_id', user.user_id) + .eq('event_type', 'post_rejected') + .gte('created_at', sevenDaysAgo); + + // Posts created in last 7 days + const { count: postsCreated7d } = await serviceClient + .from('posts') + .select('*', { count: 'exact', head: true }) + .eq('author_id', user.user_id) + .gte('created_at', sevenDaysAgo); + + // Reports filed by user + const { data: reportsFiled } = await serviceClient + .from('reports') + .select('id, status') + .eq('reporter_id', user.user_id); + + const falseReports = reportsFiled?.filter((r) => r.status === 'dismissed').length || 0; + const validatedReports = reportsFiled?.filter((r) => r.status === 'resolved').length || 0; + + // Days since signup + const { data: profile } = await serviceClient + .from('profiles') + .select('created_at') + .eq('id', user.user_id) + .single(); + + const daysSinceSignup = profile + ? Math.floor((Date.now() - new Date(profile.created_at).getTime()) / (1000 * 60 * 60 * 24)) + : 0; + + // 3. Calculate harmony adjustment + const inputs: HarmonyInputs = { + user_id: user.user_id, + blocks_received_7d: blocks7d || 0, + blocks_received_30d: blocks30d || 0, + trusted_reports_against: trustedReportsCount, + total_reports_against: reportsAgainst?.length || 0, + posts_rejected_7d: rejectedPosts?.length || 0, + posts_created_7d: postsCreated7d || 0, + false_reports_filed: falseReports, + validated_reports_filed: validatedReports, + days_since_signup: daysSinceSignup, + current_harmony_score: user.harmony_score, + current_tier: user.tier, + }; + + const adjustment = calculateHarmonyAdjustment(inputs); + + // 4. Update trust state if score changed + if (adjustment.delta !== 0) { + const { error: updateError } = await serviceClient + .from('trust_state') + .update({ + harmony_score: adjustment.new_score, + tier: adjustment.new_tier, + updated_at: new Date().toISOString(), + }) + .eq('user_id', user.user_id); + + if (updateError) { + console.error(`Error updating trust state for ${user.user_id}:`, updateError); + errorCount++; + continue; + } + + // 5. Log the adjustment + await serviceClient.rpc('log_audit_event', { + p_actor_id: null, // system action + p_event_type: 'harmony_recalculated', + p_payload: { + user_id: user.user_id, + old_score: user.harmony_score, + new_score: adjustment.new_score, + delta: adjustment.delta, + old_tier: user.tier, + new_tier: adjustment.new_tier, + reason: adjustment.reason, + }, + }); + + updatedCount++; + } + } catch (userError) { + console.error(`Error processing user ${user.user_id}:`, userError); + errorCount++; + } + } + + return new Response( + JSON.stringify({ + success: true, + total_users: users.length, + updated: updatedCount, + errors: errorCount, + message: 'Harmony score recalculation complete', + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + } catch (error) { + console.error('Unexpected error:', error); + return new Response(JSON.stringify({ error: 'Internal server error' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +}); diff --git a/_legacy/supabase/functions/cleanup-expired-content/config.toml b/_legacy/supabase/functions/cleanup-expired-content/config.toml new file mode 100644 index 0000000..4d86786 --- /dev/null +++ b/_legacy/supabase/functions/cleanup-expired-content/config.toml @@ -0,0 +1 @@ +verify_jwt = false diff --git a/_legacy/supabase/functions/cleanup-expired-content/index.ts b/_legacy/supabase/functions/cleanup-expired-content/index.ts new file mode 100644 index 0000000..357345a --- /dev/null +++ b/_legacy/supabase/functions/cleanup-expired-content/index.ts @@ -0,0 +1,157 @@ +import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'; +import { S3Client, DeleteObjectCommand } from 'https://esm.sh/@aws-sdk/client-s3@3.470.0'; +import { createServiceClient } from '../_shared/supabase-client.ts'; + +const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || 'https://sojorn.net'; + +const corsHeaders = { + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +const R2_ENDPOINT = (Deno.env.get('R2_ENDPOINT') ?? '').trim(); +const R2_ACCOUNT_ID = (Deno.env.get('R2_ACCOUNT_ID') ?? '').trim(); +const R2_ACCESS_KEY_ID = (Deno.env.get('R2_ACCESS_KEY_ID') ?? Deno.env.get('R2_ACCESS_KEY') ?? '').trim(); +const R2_SECRET_ACCESS_KEY = (Deno.env.get('R2_SECRET_ACCESS_KEY') ?? Deno.env.get('R2_SECRET_KEY') ?? '').trim(); +const R2_BUCKET_NAME = (Deno.env.get('R2_BUCKET_NAME') ?? '').trim(); +const DEFAULT_BUCKET_NAME = 'sojorn-media'; + +const RESOLVED_ENDPOINT = R2_ENDPOINT || (R2_ACCOUNT_ID ? `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com` : ''); +const RESOLVED_BUCKET = R2_BUCKET_NAME || DEFAULT_BUCKET_NAME; + +const supabase = createServiceClient(); + +function extractObjectKey(imageUrl: string, bucketName: string): string | null { + try { + const url = new URL(imageUrl); + let key = url.pathname.replace(/^\/+/, ''); + if (!key) return null; + if (bucketName && key.startsWith(`${bucketName}/`)) { + key = key.slice(bucketName.length + 1); + } + return decodeURIComponent(key); + } catch (_) { + return null; + } +} + +serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: { ...corsHeaders, 'Access-Control-Allow-Methods': 'POST OPTIONS' } }); + } + + if (req.method !== 'POST') { + return new Response(JSON.stringify({ error: 'Method not allowed' }), { + status: 405, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + if (!RESOLVED_ENDPOINT || !R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY || !RESOLVED_BUCKET) { + return new Response(JSON.stringify({ error: 'Missing R2 configuration' }), { + status: 500, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + const r2 = new S3Client({ + region: 'auto', + endpoint: RESOLVED_ENDPOINT, + credentials: { + accessKeyId: R2_ACCESS_KEY_ID, + secretAccessKey: R2_SECRET_ACCESS_KEY, + }, + forcePathStyle: true, + }); + + try { + const maxBatches = 50; + const batchSize = 100; + const maxRuntimeMs = 25000; + const startTime = Date.now(); + + let processedCount = 0; + let deletedCount = 0; + let skippedCount = 0; + let batches = 0; + + while (batches < maxBatches && Date.now() - startTime < maxRuntimeMs) { + const { data: posts, error } = await supabase + .from('posts') + .select('id, image_url, expires_at') + .lt('expires_at', new Date().toISOString()) + .order('expires_at', { ascending: true }) + .limit(batchSize); + + if (error) { + console.error('Error fetching expired posts:', error); + return new Response(JSON.stringify({ error: 'Failed to fetch expired posts' }), { + status: 500, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + const expiredPosts = posts ?? []; + if (expiredPosts.length === 0) break; + + processedCount += expiredPosts.length; + batches += 1; + + for (const post of expiredPosts) { + if (post.image_url) { + const key = extractObjectKey(post.image_url, RESOLVED_BUCKET); + if (!key) { + console.error('Could not parse image key:', { post_id: post.id, image_url: post.image_url }); + skippedCount += 1; + continue; + } + + try { + await r2.send( + new DeleteObjectCommand({ + Bucket: RESOLVED_BUCKET, + Key: key, + }) + ); + } catch (error) { + console.error('R2 deletion failed:', { post_id: post.id, error }); + skippedCount += 1; + continue; + } + } + + const { error: deleteError } = await supabase + .from('posts') + .delete() + .eq('id', post.id); + + if (deleteError) { + console.error('Failed to delete post row:', { post_id: post.id, error: deleteError }); + skippedCount += 1; + continue; + } + + deletedCount += 1; + } + } + + return new Response( + JSON.stringify({ + processed: processedCount, + deleted: deletedCount, + skipped: skippedCount, + batches, + }), + { + status: 200, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + } + ); + } catch (error) { + console.error('Unexpected cleanup error:', error); + return new Response(JSON.stringify({ error: 'Internal server error' }), { + status: 500, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } +}); diff --git a/_legacy/supabase/functions/consume_one_time_prekey/config.toml b/_legacy/supabase/functions/consume_one_time_prekey/config.toml new file mode 100644 index 0000000..4d86786 --- /dev/null +++ b/_legacy/supabase/functions/consume_one_time_prekey/config.toml @@ -0,0 +1 @@ +verify_jwt = false diff --git a/_legacy/supabase/functions/consume_one_time_prekey/index.ts b/_legacy/supabase/functions/consume_one_time_prekey/index.ts new file mode 100644 index 0000000..e406a51 --- /dev/null +++ b/_legacy/supabase/functions/consume_one_time_prekey/index.ts @@ -0,0 +1,98 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts" +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +} + +interface ConsumeOneTimePrekeyRequest { + target_user_id: string +} + +serve(async (req) => { + // Handle CORS + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeaders }) + } + + try { + // Create a Supabase client with the Auth context of the logged in user. + const supabaseClient = createClient( + Deno.env.get('SUPABASE_URL') ?? '', + Deno.env.get('SUPABASE_ANON_KEY') ?? '', + { + global: { + headers: { Authorization: req.headers.get('Authorization')! }, + }, + } + ) + + // Get the current user + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }) + } + + // Parse request body + const { target_user_id }: ConsumeOneTimePrekeyRequest = await req.json() + + if (!target_user_id) { + return new Response(JSON.stringify({ error: 'target_user_id is required' }), { + status: 400, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }) + } + + console.log(`Consuming one-time pre-key for user: ${target_user_id}`) + + // Get and consume (delete) the oldest one-time pre-key for the target user + const { data: prekey, error } = await supabaseClient + .from('one_time_prekeys') + .delete() + .eq('user_id', target_user_id) + .order('created_at') + .limit(1) + .select('id, public_key') + .single() + + if (error) { + console.error('Error consuming one-time pre-key:', error) + return new Response(JSON.stringify({ error: 'Failed to consume one-time pre-key' }), { + status: 500, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }) + } + + if (!prekey) { + console.log(`No one-time pre-keys available for user: ${target_user_id}`) + // Return null to indicate no pre-key was available + return new Response(JSON.stringify(null), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }) + } + + console.log(`Successfully consumed one-time pre-key: ${prekey.id} for user: ${target_user_id}`) + + // Return the consumed pre-key data + return new Response(JSON.stringify({ + key_id: prekey.id, + public_key: prekey.public_key, + }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }) + + } catch (error) { + console.error('Unexpected error:', error) + return new Response(JSON.stringify({ error: 'Internal server error' }), { + status: 500, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }) + } +}) diff --git a/_legacy/supabase/functions/create-beacon/config.toml b/_legacy/supabase/functions/create-beacon/config.toml new file mode 100644 index 0000000..4d86786 --- /dev/null +++ b/_legacy/supabase/functions/create-beacon/config.toml @@ -0,0 +1 @@ +verify_jwt = false diff --git a/_legacy/supabase/functions/create-beacon/index.ts b/_legacy/supabase/functions/create-beacon/index.ts new file mode 100644 index 0000000..f248f91 --- /dev/null +++ b/_legacy/supabase/functions/create-beacon/index.ts @@ -0,0 +1,181 @@ +import { serve } from 'https://deno.land/std@0.177.0/http/server.ts' +import { createSupabaseClient, createServiceClient } from '../_shared/supabase-client.ts' +import { trySignR2Url } from '../_shared/r2_signer.ts' + +interface BeaconRequest { + lat: number + long: number + title: string + description: string + type: 'police' | 'checkpoint' | 'taskForce' | 'hazard' | 'safety' | 'community' + image_url?: string +} + +interface ResponseData { + beacon?: Record + error?: string +} + +serve(async (req: Request) => { + try { + // Get auth header + const authHeader = req.headers.get('Authorization') + if (!authHeader) { + return new Response( + JSON.stringify({ error: 'Missing Authorization header' } as ResponseData), + { status: 401, headers: { 'Content-Type': 'application/json' } } + ) + } + + const supabase = createSupabaseClient(authHeader) + const { data: { user }, error: userError } = await supabase.auth.getUser() + + if (userError || !user) { + console.error('Auth error:', userError) + return new Response( + JSON.stringify({ error: 'Unauthorized' } as ResponseData), + { status: 401, headers: { 'Content-Type': 'application/json' } } + ) + } + + // Use service role for DB operations + const supabaseAdmin = createServiceClient() + + // Parse request body + const body = await req.json() + + // Convert lat/long to numbers (handles both int and double from client) + const beaconReq: BeaconRequest = { + lat: Number(body.lat), + long: Number(body.long), + title: body.title, + description: body.description, + type: body.type, + image_url: body.image_url + } + + // Validate required fields + if (!beaconReq.lat || !beaconReq.long || !beaconReq.title || !beaconReq.description || !beaconReq.type) { + return new Response( + JSON.stringify({ error: 'Missing required fields: lat, long, title, description, type' } as ResponseData), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ) + } + + // Validate beacon type + const validTypes = ['police', 'checkpoint', 'taskForce', 'hazard', 'safety', 'community'] + if (!validTypes.includes(beaconReq.type)) { + return new Response( + JSON.stringify({ error: 'Invalid beacon type. Must be: police, checkpoint, taskForce, hazard, safety, or community' } as ResponseData), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ) + } + + // Get user's profile and trust score (use admin client to bypass RLS) + const { data: profile, error: profileError } = await supabaseAdmin + .from('profiles') + .select('id, trust_state(harmony_score)') + .eq('id', user.id) + .single() + + if (profileError || !profile) { + return new Response( + JSON.stringify({ error: 'Profile not found' } as ResponseData), + { status: 404, headers: { 'Content-Type': 'application/json' } } + ) + } + + // Get a default category for beacons (search by slug to match seed data) + const { data: category } = await supabaseAdmin + .from('categories') + .select('id') + .eq('slug', 'beacon_alerts') + .single() + + let categoryId = category?.id + + if (!categoryId) { + // Create the beacon category if it doesn't exist (with service role bypass) + const { data: newCategory, error: insertError } = await supabaseAdmin + .from('categories') + .insert({ slug: 'beacon_alerts', name: 'Beacon Alerts', description: 'Community safety and alert posts' }) + .select('id') + .single() + + if (insertError || !newCategory) { + console.error('Failed to create beacon category:', insertError) + return new Response( + JSON.stringify({ error: 'Failed to create beacon category' } as ResponseData), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ) + } + + categoryId = newCategory.id + } + + // Get user's trust score for initial confidence + const trustScore = profile.trust_state?.harmony_score ?? 0.5 + const initialConfidence = 0.5 + (trustScore * 0.3) // Start at 50-80% based on trust + + // Create the beacon post + const { data: beacon, error: beaconError } = await supabaseAdmin + .from('posts') + .insert({ + author_id: user.id, + category_id: categoryId, + body: beaconReq.description, + is_beacon: true, + beacon_type: beaconReq.type, + location: `SRID=4326;POINT(${beaconReq.long} ${beaconReq.lat})`, + confidence_score: Math.min(1.0, Math.max(0.0, initialConfidence)), + is_active_beacon: true, + image_url: beaconReq.image_url, + status: 'active', + tone_label: 'neutral', + cis_score: 0.8, + allow_chain: false // Beacons don't allow chaining + }) + .select() + .single() + + if (beaconError) { + console.error('Error creating beacon:', beaconError) + return new Response( + JSON.stringify({ error: `Failed to create beacon: ${beaconError.message}` } as ResponseData), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ) + } + + // Get full beacon data with author info + const { data: fullBeacon } = await supabaseAdmin + .from('posts') + .select(` + *, + author:profiles!posts_author_id_fkey ( + id, + handle, + display_name, + avatar_url + ) + `) + .eq('id', beacon.id) + .single() + + let signedBeacon = fullBeacon + if (fullBeacon?.image_url) { + signedBeacon = { ...fullBeacon, image_url: await trySignR2Url(fullBeacon.image_url) } + } + + return new Response( + JSON.stringify({ beacon: signedBeacon } as ResponseData), + { status: 201, headers: { 'Content-Type': 'application/json' } } + ) + + } catch (error) { + console.error('Unexpected error:', error) + return new Response( + JSON.stringify({ error: 'Internal server error' } as ResponseData), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ) + } +}) diff --git a/_legacy/supabase/functions/deactivate-account/config.toml b/_legacy/supabase/functions/deactivate-account/config.toml new file mode 100644 index 0000000..4d86786 --- /dev/null +++ b/_legacy/supabase/functions/deactivate-account/config.toml @@ -0,0 +1 @@ +verify_jwt = false diff --git a/_legacy/supabase/functions/deactivate-account/index.ts b/_legacy/supabase/functions/deactivate-account/index.ts new file mode 100644 index 0000000..451c0a5 --- /dev/null +++ b/_legacy/supabase/functions/deactivate-account/index.ts @@ -0,0 +1,168 @@ +/** + * POST /deactivate-account - Deactivate user account (reactivatable within 30 days) + * POST /deactivate-account/reactivate - Reactivate a deactivated account + * + * Design intent: + * - Allows users to temporarily deactivate their account + * - Account can be reactivated by logging in + * - Profile and posts are hidden while deactivated + */ + +import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'; +import { createSupabaseClient } from '../_shared/supabase-client.ts'; + +const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || 'https://sojorn.net'; + +serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { + headers: { + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + 'Access-Control-Allow-Methods': 'POST', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', + }, + }); + } + + try { + const authHeader = req.headers.get('Authorization'); + if (!authHeader) { + return new Response(JSON.stringify({ error: 'Missing authorization header' }), { + status: 401, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + }, + }); + } + + const supabase = createSupabaseClient(authHeader); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError || !user) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + }, + }); + } + + if (req.method !== 'POST') { + return new Response(JSON.stringify({ error: 'Method not allowed' }), { + status: 405, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + }, + }); + } + + const url = new URL(req.url); + const isReactivate = url.pathname.endsWith('/reactivate'); + + if (isReactivate) { + // Reactivate account + const { data, error } = await supabase + .rpc('reactivate_account', { p_user_id: user.id }); + + if (error) { + console.error('Error reactivating account:', error); + return new Response(JSON.stringify({ + error: 'Failed to reactivate account', + details: error.message + }), { + status: 500, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + }, + }); + } + + if (!data || !data.success) { + return new Response(JSON.stringify({ + error: data?.error || 'Account is not deactivated' + }), { + status: 400, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + }, + }); + } + + return new Response( + JSON.stringify({ + success: true, + message: 'Account reactivated successfully', + }), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + }, + } + ); + } else { + // Deactivate account + const { data, error } = await supabase + .rpc('deactivate_account', { p_user_id: user.id }); + + if (error) { + console.error('Error deactivating account:', error); + return new Response(JSON.stringify({ + error: 'Failed to deactivate account', + details: error.message + }), { + status: 500, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + }, + }); + } + + if (!data || !data.success) { + return new Response(JSON.stringify({ + error: data?.error || 'Account already deactivated' + }), { + status: 400, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + }, + }); + } + + return new Response( + JSON.stringify({ + success: true, + message: 'Account deactivated successfully. You can reactivate it anytime by logging in.', + deactivated_at: data.deactivated_at, + }), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + }, + } + ); + } + } catch (error) { + console.error('Unexpected error:', error); + return new Response(JSON.stringify({ error: 'Internal server error' }), { + status: 500, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + }, + }); + } +}); diff --git a/_legacy/supabase/functions/delete-account/config.toml b/_legacy/supabase/functions/delete-account/config.toml new file mode 100644 index 0000000..4d86786 --- /dev/null +++ b/_legacy/supabase/functions/delete-account/config.toml @@ -0,0 +1 @@ +verify_jwt = false diff --git a/_legacy/supabase/functions/delete-account/index.ts b/_legacy/supabase/functions/delete-account/index.ts new file mode 100644 index 0000000..9d90ad7 --- /dev/null +++ b/_legacy/supabase/functions/delete-account/index.ts @@ -0,0 +1,170 @@ +/** + * POST /delete-account - Request permanent account deletion (30-day waiting period) + * POST /delete-account/cancel - Cancel a pending deletion request + * + * Design intent: + * - Allows users to request permanent account deletion + * - 30-day waiting period before actual deletion + * - Users can cancel the request within 30 days + * - After 30 days, account is permanently deleted by a scheduled job + */ + +import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'; +import { createSupabaseClient } from '../_shared/supabase-client.ts'; + +const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || 'https://sojorn.net'; + +serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { + headers: { + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + 'Access-Control-Allow-Methods': 'POST', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', + }, + }); + } + + try { + const authHeader = req.headers.get('Authorization'); + if (!authHeader) { + return new Response(JSON.stringify({ error: 'Missing authorization header' }), { + status: 401, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + }, + }); + } + + const supabase = createSupabaseClient(authHeader); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError || !user) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + }, + }); + } + + if (req.method !== 'POST') { + return new Response(JSON.stringify({ error: 'Method not allowed' }), { + status: 405, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + }, + }); + } + + const url = new URL(req.url); + const isCancel = url.pathname.endsWith('/cancel'); + + if (isCancel) { + // Cancel deletion request + const { data, error } = await supabase + .rpc('cancel_account_deletion', { p_user_id: user.id }); + + if (error) { + console.error('Error cancelling deletion:', error); + return new Response(JSON.stringify({ + error: 'Failed to cancel deletion request', + details: error.message + }), { + status: 500, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + }, + }); + } + + if (!data || !data.success) { + return new Response(JSON.stringify({ + error: data?.error || 'No pending deletion request found' + }), { + status: 400, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + }, + }); + } + + return new Response( + JSON.stringify({ + success: true, + message: 'Account deletion request cancelled successfully', + }), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + }, + } + ); + } else { + // Request account deletion + const { data, error } = await supabase + .rpc('request_account_deletion', { p_user_id: user.id }); + + if (error) { + console.error('Error requesting deletion:', error); + return new Response(JSON.stringify({ + error: 'Failed to request account deletion', + details: error.message + }), { + status: 500, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + }, + }); + } + + if (!data || !data.success) { + return new Response(JSON.stringify({ + error: data?.error || 'Account deletion already requested' + }), { + status: 400, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + }, + }); + } + + return new Response( + JSON.stringify({ + success: true, + message: 'Account deletion requested. Your account will be permanently deleted in 30 days. You can cancel this request anytime by logging in.', + deletion_date: data.deletion_date, + deletion_requested_at: data.deletion_requested_at, + }), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + }, + } + ); + } + } catch (error) { + console.error('Unexpected error:', error); + return new Response(JSON.stringify({ error: 'Internal server error' }), { + status: 500, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + }, + }); + } +}); diff --git a/_legacy/supabase/functions/deno.jsonc b/_legacy/supabase/functions/deno.jsonc new file mode 100644 index 0000000..2b13e2c --- /dev/null +++ b/_legacy/supabase/functions/deno.jsonc @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "lib": ["deno.ns", "dom"] + } +} diff --git a/_legacy/supabase/functions/e2ee_session_manager/config.toml b/_legacy/supabase/functions/e2ee_session_manager/config.toml new file mode 100644 index 0000000..4d86786 --- /dev/null +++ b/_legacy/supabase/functions/e2ee_session_manager/config.toml @@ -0,0 +1 @@ +verify_jwt = false diff --git a/_legacy/supabase/functions/e2ee_session_manager/index.ts b/_legacy/supabase/functions/e2ee_session_manager/index.ts new file mode 100644 index 0000000..c52fc53 --- /dev/null +++ b/_legacy/supabase/functions/e2ee_session_manager/index.ts @@ -0,0 +1,686 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; + +/** + * E2EE Session Manager Edge Function + * + * This function handles session management, cleanup, and recovery for the + * end-to-end encryption system. It operates on metadata only and cannot + * access the actual encrypted message content. + * + * Security Properties: + * - Blind to message content (only handles metadata) + * - Enforces proper session protocols + * - Handles cleanup and recovery scenarios + * - Maintains perfect forward secrecy + */ + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', + 'Content-Type': 'application/json', +}; + +serve(async (req) => { + // Handle CORS preflight + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeaders }); + } + + try { + // Verify authorization header exists + const authHeader = req.headers.get('Authorization'); + if (!authHeader) { + return new Response(JSON.stringify({ error: 'Missing authorization header' }), { + status: 401, + headers: corsHeaders, + }); + } + + // Create auth client to verify the user's JWT + const authClient = createClient( + Deno.env.get('SUPABASE_URL') ?? '', + Deno.env.get('SUPABASE_ANON_KEY') ?? '', + { + global: { + headers: { + Authorization: authHeader, + apikey: Deno.env.get('SUPABASE_ANON_KEY') ?? '', + }, + }, + } + ); + + // Verify the JWT and get the authenticated user + const { data: { user }, error: authError } = await authClient.auth.getUser(); + if (authError || !user) { + console.error('[E2EE Session Manager] Auth error:', authError); + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: corsHeaders, + }); + } + + const serviceRoleKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY'); + if (!serviceRoleKey) { + console.error('[E2EE Session Manager] Missing SUPABASE_SERVICE_ROLE_KEY'); + return new Response(JSON.stringify({ error: 'Server misconfiguration: missing service role key' }), { + status: 500, + headers: corsHeaders, + }); + } + + // Create service client for database operations (bypasses RLS) + const supabase = createClient( + Deno.env.get('SUPABASE_URL') ?? '', + serviceRoleKey + ); + + // Parse request body + const { action, userId, recipientId, conversationId, hasSession } = await req.json(); + + if (!action) { + return new Response(JSON.stringify({ error: 'Action is required' }), { + status: 400, + headers: corsHeaders, + }); + } + + // Use the authenticated user's ID, ignoring any userId in the request body + // This prevents users from impersonating others + const authenticatedUserId = user.id; + + console.log(`[E2EE Session Manager] ${action} requested by ${authenticatedUserId}`); + + switch (action) { + case 'reset_session': + if (!recipientId) { + return new Response(JSON.stringify({ error: 'recipientId is required' }), { + status: 400, + headers: corsHeaders, + }); + } + if (recipientId === authenticatedUserId) { + return new Response(JSON.stringify({ error: 'recipientId must be different from userId' }), { + status: 400, + headers: corsHeaders, + }); + } + { + const profileCheck = await ensureProfilesExist(supabase, [authenticatedUserId, recipientId]); + if (profileCheck) return profileCheck; + } + return handleResetSession(supabase, authenticatedUserId, recipientId, corsHeaders); + case 'cleanup_conversation': + if (!conversationId) { + return new Response(JSON.stringify({ error: 'conversationId is required' }), { + status: 400, + headers: corsHeaders, + }); + } + return handleCleanupConversation(supabase, authenticatedUserId, conversationId, corsHeaders); + case 'verify_session': + if (!recipientId) { + return new Response(JSON.stringify({ error: 'recipientId is required' }), { + status: 400, + headers: corsHeaders, + }); + } + if (recipientId === authenticatedUserId) { + return new Response(JSON.stringify({ error: 'recipientId must be different from userId' }), { + status: 400, + headers: corsHeaders, + }); + } + { + const profileCheck = await ensureProfilesExist(supabase, [authenticatedUserId, recipientId]); + if (profileCheck) return profileCheck; + } + return handleVerifySession(supabase, authenticatedUserId, recipientId, corsHeaders); + case 'force_key_refresh': + return handleForceKeyRefresh(supabase, authenticatedUserId, corsHeaders); + case 'sync_session_state': + if (!recipientId) { + return new Response(JSON.stringify({ error: 'recipientId is required' }), { + status: 400, + headers: corsHeaders, + }); + } + if (recipientId === authenticatedUserId) { + return new Response(JSON.stringify({ error: 'recipientId must be different from userId' }), { + status: 400, + headers: corsHeaders, + }); + } + if (typeof hasSession !== 'boolean') { + return new Response(JSON.stringify({ error: 'hasSession must be a boolean' }), { + status: 400, + headers: corsHeaders, + }); + } + { + const profileCheck = await ensureProfilesExist(supabase, [authenticatedUserId, recipientId]); + if (profileCheck) return profileCheck; + } + return handleSyncSessionState(supabase, authenticatedUserId, recipientId, hasSession, corsHeaders); + case 'get_session_state': + if (!recipientId) { + return new Response(JSON.stringify({ error: 'recipientId is required' }), { + status: 400, + headers: corsHeaders, + }); + } + if (recipientId === authenticatedUserId) { + return new Response(JSON.stringify({ error: 'recipientId must be different from userId' }), { + status: 400, + headers: corsHeaders, + }); + } + { + const profileCheck = await ensureProfilesExist(supabase, [authenticatedUserId, recipientId]); + if (profileCheck) return profileCheck; + } + return handleGetSessionState(supabase, authenticatedUserId, recipientId, corsHeaders); + default: + return new Response(JSON.stringify({ error: 'Invalid action' }), { + status: 400, + headers: corsHeaders, + }); + } + } catch (error) { + console.error('[E2EE Session Manager] Error:', error); + return new Response(JSON.stringify({ error: error.message }), { + status: 500, + headers: corsHeaders, + }); + } +}); + +async function ensureProfilesExist( + supabase: any, + userIds: string[] +): Promise { + const uniqueIds = [...new Set(userIds)].filter(Boolean); + if (uniqueIds.length === 0) { + return new Response(JSON.stringify({ error: 'Invalid user IDs' }), { + status: 400, + headers: corsHeaders, + }); + } + + const { data, error } = await supabase + .from('profiles') + .select('id') + .in('id', uniqueIds); + + if (error) { + console.error('[E2EE Session Manager] Failed to verify profiles:', error); + return new Response(JSON.stringify({ error: 'Failed to verify profiles' }), { + status: 500, + headers: corsHeaders, + }); + } + + const found = new Set((data ?? []).map((row: { id: string }) => row.id)); + const missing = uniqueIds.filter((id) => !found.has(id)); + if (missing.length > 0) { + return new Response(JSON.stringify({ error: 'Profile not found', missingIds: missing }), { + status: 404, + headers: corsHeaders, + }); + } + + return null; +} + +/** + * Reset session between two users + * - Clears session keys for both parties + * - Forces re-establishment of encryption session + */ +async function handleResetSession(supabase: any, userId: string, recipientId: string, headers: Record) { + try { + console.log(`[E2EE Session Manager] Resetting session between ${userId} and ${recipientId}`); + + // Note: We can't directly clear flutter_secure_storage from the server, + // but we can send commands that the client will process + + // Store a session reset command in the database + const { error } = await supabase + .from('e2ee_session_commands') + .insert({ + user_id: userId, + recipient_id: recipientId, + command_type: 'session_reset', + status: 'pending', + created_at: new Date().toISOString(), + }); + + if (error) { + console.error('[E2EE Session Manager] Failed to store session reset command:', error); + return new Response(JSON.stringify({ success: false, error: error.message }), { + status: 500, + headers, + }); + } + + // Send a realtime notification to both parties + // The client will pick this up and clear their local session keys + await supabase + .from('e2ee_session_events') + .insert({ + user_id: userId, + event_type: 'session_reset', + recipient_id: recipientId, + timestamp: new Date().toISOString(), + }); + + await supabase + .from('e2ee_session_events') + .insert({ + user_id: recipientId, + event_type: 'session_reset', + recipient_id: userId, + timestamp: new Date().toISOString(), + }); + + return new Response(JSON.stringify({ + success: true, + message: 'Session reset initiated for both parties' + }), { + status: 200, + headers, + }); + } catch (error) { + console.error('[E2EE Session Manager] Session reset failed:', error); + return new Response(JSON.stringify({ success: false, error: error.message }), { + status: 500, + headers, + }); + } +} + +/** + * Clean up conversation and related data + * - Deletes messages (if allowed by RLS) + * - Clears session keys + * - Handles cleanup of related metadata + */ +async function handleCleanupConversation(supabase: any, userId: string, conversationId: string, headers: Record) { + try { + console.log(`[E2EE Session Manager] Cleaning up conversation ${conversationId} for ${userId}`); + + // Get conversation details + const { data: conversation, error: convError } = await supabase + .from('encrypted_conversations') + .select('participant_a, participant_b') + .eq('id', conversationId) + .single(); + + if (convError || !conversation) { + console.error('[E2EE Session Manager] Conversation not found:', convError); + return new Response(JSON.stringify({ success: false, error: 'Conversation not found' }), { + status: 404, + headers, + }); + } + + // Determine the other participant + const otherUserId = conversation.participant_a === userId + ? conversation.participant_b + : conversation.participant_a; + + // Delete messages sent by the current user + const { error: deleteError } = await supabase + .from('encrypted_messages') + .delete() + .eq('conversation_id', conversationId) + .eq('sender_id', userId); + + if (deleteError) { + console.warn('[E2EE Session Manager] Could not delete all messages:', deleteError); + // This is acceptable - RLS might prevent deletion of some messages + } + + // Store cleanup command for both parties + await supabase + .from('e2ee_session_commands') + .insert({ + user_id: userId, + conversation_id: conversationId, + command_type: 'conversation_cleanup', + status: 'pending', + created_at: new Date().toISOString(), + }); + + await supabase + .from('e2ee_session_commands') + .insert({ + user_id: otherUserId, + conversation_id: conversationId, + command_type: 'conversation_cleanup', + status: 'pending', + created_at: new Date().toISOString(), + }); + + // Send realtime notifications + await supabase + .from('e2ee_session_events') + .insert({ + user_id: userId, + event_type: 'conversation_cleanup', + conversation_id: conversationId, + timestamp: new Date().toISOString(), + }); + + await supabase + .from('e2ee_session_events') + .insert({ + user_id: otherUserId, + event_type: 'conversation_cleanup', + conversation_id: conversationId, + timestamp: new Date().toISOString(), + }); + + return new Response(JSON.stringify({ + success: true, + message: 'Conversation cleanup initiated', + otherUserId: otherUserId + }), { + status: 200, + headers, + }); + } catch (error) { + console.error('[E2EE Session Manager] Conversation cleanup failed:', error); + return new Response(JSON.stringify({ success: false, error: error.message }), { + status: 500, + headers, + }); + } +} + +/** + * Verify if a session exists between two users + * - Checks for existing session metadata + * - Returns session status without exposing keys + */ +async function handleVerifySession(supabase: any, userId: string, recipientId: string, headers: Record) { + try { + console.log(`[E2EE Session Manager] Verifying session between ${userId} and ${recipientId}`); + + // Check if both users have signal keys (indicates they can establish sessions) + const { data: userKeys, error: userError } = await supabase + .from('signal_keys') + .select('user_id') + .eq('user_id', userId) + .single(); + + const { data: recipientKeys, error: recipientError } = await supabase + .from('signal_keys') + .select('user_id') + .eq('user_id', recipientId) + .single(); + + if (userError || !userKeys) { + return new Response(JSON.stringify({ + success: false, + error: 'User has no encryption keys', + userHasKeys: false, + recipientHasKeys: !!recipientKeys + }), { + status: 400, + headers, + }); + } + + if (recipientError || !recipientKeys) { + return new Response(JSON.stringify({ + success: false, + error: 'Recipient has no encryption keys', + userHasKeys: true, + recipientHasKeys: false + }), { + status: 400, + headers, + }); + } + + // Check if there's an existing conversation (indicates potential session) + const { data: conversation, error: convError } = await supabase + .from('encrypted_conversations') + .select('id, last_message_at') + .or(`participant_a.eq.${userId},participant_b.eq.${userId}`) + .or(`participant_a.eq.${recipientId},participant_b.eq.${recipientId}`) + .single(); + + return new Response(JSON.stringify({ + success: true, + userHasKeys: true, + recipientHasKeys: true, + hasConversation: !!conversation, + conversationId: conversation?.id, + lastMessageAt: conversation?.last_message_at + }), { + status: 200, + headers, + }); + } catch (error) { + console.error('[E2EE Session Manager] Session verification failed:', error); + return new Response(JSON.stringify({ success: false, error: error.message }), { + status: 500, + headers, + }); + } +} + +/** + * Force key refresh for a user + * - Triggers rotation of encryption keys + * - Handles key upload and cleanup + */ +async function handleForceKeyRefresh(supabase: any, userId: string, headers: Record) { + try { + console.log(`[E2EE Session Manager] Forcing key refresh for ${userId}`); + + // Store a key refresh command + const { error } = await supabase + .from('e2ee_session_commands') + .insert({ + user_id: userId, + command_type: 'key_refresh', + status: 'pending', + created_at: new Date().toISOString(), + }); + + if (error) { + console.error('[E2EE Session Manager] Failed to store key refresh command:', error); + return new Response(JSON.stringify({ success: false, error: error.message }), { + status: 500, + headers, + }); + } + + // Send realtime notification + await supabase + .from('e2ee_session_events') + .insert({ + user_id: userId, + event_type: 'key_refresh', + timestamp: new Date().toISOString(), + }); + + return new Response(JSON.stringify({ + success: true, + message: 'Key refresh initiated', + note: 'Client should generate new keys and upload them' + }), { + status: 200, + headers, + }); + } catch (error) { + console.error('[E2EE Session Manager] Key refresh failed:', error); + return new Response(JSON.stringify({ success: false, error: error.message }), { + status: 500, + headers, + }); + } +} + +/** + * Sync session state between two users + * - Updates the server-side session state tracking + * - Detects mismatches and triggers recovery if needed + */ +async function handleSyncSessionState( + supabase: any, + userId: string, + recipientId: string, + hasSession: boolean, + headers: Record +) { + try { + console.log(`[E2EE Session Manager] Syncing session state: ${userId} -> ${recipientId}, hasSession: ${hasSession}`); + + if (!recipientId) { + return new Response(JSON.stringify({ error: 'recipientId is required' }), { + status: 400, + headers, + }); + } + + // Call the database function to update session state + const { data, error } = await supabase.rpc('update_e2ee_session_state', { + p_user_id: userId, + p_peer_id: recipientId, + p_has_session: hasSession, + }); + + if (error) { + console.error('[E2EE Session Manager] Failed to update session state:', error); + return new Response(JSON.stringify({ success: false, error: error.message }), { + status: 500, + headers, + }); + } + + const result = data as { + success: boolean; + user_has_session: boolean; + peer_has_session: boolean; + session_mismatch: boolean; + peer_session_version: number; + }; + + // If there's a mismatch, notify both parties + if (result.session_mismatch) { + console.log(`[E2EE Session Manager] Session mismatch detected between ${userId} and ${recipientId}`); + + // Insert session_mismatch event for both users + await supabase.from('e2ee_session_events').insert([ + { + user_id: userId, + event_type: 'session_mismatch', + recipient_id: recipientId, + error_details: { + user_has_session: result.user_has_session, + peer_has_session: result.peer_has_session, + }, + timestamp: new Date().toISOString(), + }, + { + user_id: recipientId, + event_type: 'session_mismatch', + recipient_id: userId, + error_details: { + user_has_session: result.peer_has_session, + peer_has_session: result.user_has_session, + }, + timestamp: new Date().toISOString(), + }, + ]); + } + + return new Response(JSON.stringify({ + success: true, + userHasSession: result.user_has_session, + peerHasSession: result.peer_has_session, + sessionMismatch: result.session_mismatch, + peerSessionVersion: result.peer_session_version, + }), { + status: 200, + headers, + }); + } catch (error) { + console.error('[E2EE Session Manager] Sync session state failed:', error); + return new Response(JSON.stringify({ success: false, error: error.message }), { + status: 500, + headers, + }); + } +} + +/** + * Get current session state between two users + * - Returns session state without modifying it + * - Used to check for mismatches before sending messages + */ +async function handleGetSessionState( + supabase: any, + userId: string, + recipientId: string, + headers: Record +) { + try { + console.log(`[E2EE Session Manager] Getting session state: ${userId} <-> ${recipientId}`); + + if (!recipientId) { + return new Response(JSON.stringify({ error: 'recipientId is required' }), { + status: 400, + headers, + }); + } + + // Call the database function to get session state + const { data, error } = await supabase.rpc('get_e2ee_session_state', { + p_user_id: userId, + p_peer_id: recipientId, + }); + + if (error) { + console.error('[E2EE Session Manager] Failed to get session state:', error); + return new Response(JSON.stringify({ success: false, error: error.message }), { + status: 500, + headers, + }); + } + + const result = data as { + exists: boolean; + user_has_session: boolean; + peer_has_session: boolean; + session_mismatch: boolean; + user_session_version?: number; + peer_session_version?: number; + }; + + return new Response(JSON.stringify({ + success: true, + exists: result.exists, + userHasSession: result.user_has_session, + peerHasSession: result.peer_has_session, + sessionMismatch: result.session_mismatch, + userSessionVersion: result.user_session_version, + peerSessionVersion: result.peer_session_version, + }), { + status: 200, + headers, + }); + } catch (error) { + console.error('[E2EE Session Manager] Get session state failed:', error); + return new Response(JSON.stringify({ success: false, error: error.message }), { + status: 500, + headers, + }); + } +} diff --git a/_legacy/supabase/functions/feed-personal/config.toml b/_legacy/supabase/functions/feed-personal/config.toml new file mode 100644 index 0000000..4d86786 --- /dev/null +++ b/_legacy/supabase/functions/feed-personal/config.toml @@ -0,0 +1 @@ +verify_jwt = false diff --git a/_legacy/supabase/functions/feed-personal/index.ts b/_legacy/supabase/functions/feed-personal/index.ts new file mode 100644 index 0000000..5b7c746 --- /dev/null +++ b/_legacy/supabase/functions/feed-personal/index.ts @@ -0,0 +1,149 @@ +import { serve } from "https://deno.land/std@0.177.0/http/server.ts"; +import { createSupabaseClient, createServiceClient } from "../_shared/supabase-client.ts"; +import { trySignR2Url } from "../_shared/r2_signer.ts"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", + "Vary": "Origin", +}; + +serve(async (req: Request) => { + if (req.method === "OPTIONS") { + return new Response(null, { headers: corsHeaders }); + } + + try { + const authHeader = req.headers.get("Authorization"); + if (!authHeader) { + console.error("Missing authorization header"); + return new Response(JSON.stringify({ error: "Missing authorization header" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }); + } + + const supabase = createSupabaseClient(authHeader); + + // Don't pass JWT explicitly - let the SDK validate using its internal session + const { data: { user }, error: authError } = await supabase.auth.getUser(); + + if (authError || !user) { + console.error("Auth error in feed-personal:", authError); + console.error("User object:", user); + return new Response(JSON.stringify({ error: "Unauthorized", details: authError?.message || "No user returned" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }); + } + + const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); + if (!serviceKey) { + console.error("Missing SUPABASE_SERVICE_ROLE_KEY in function environment"); + return new Response(JSON.stringify({ error: "Server misconfigured: service key missing" }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }); + } + + // Use service client for database queries to bypass RLS + const serviceClient = createServiceClient(); + + const url = new URL(req.url); + const limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 100); + const offset = parseInt(url.searchParams.get("offset") || "0"); + + // Check if user has opted into beacon posts + const { data: profile } = await serviceClient + .from("profiles") + .select("beacon_enabled") + .eq("id", user.id) + .single(); + + const beaconEnabled = profile?.beacon_enabled || false; + + // Get list of users this person follows (with accepted status) + const { data: followingData } = await serviceClient + .from("follows") + .select("following_id") + .eq("follower_id", user.id) + .eq("status", "accepted"); + + const followingIds = (followingData || []).map((f: any) => f.following_id); + + // Include user's own posts in their feed + posts from people they follow + const authorIds = [user.id, ...followingIds]; + + // Debug: First try a simple query to see if the basic setup works + console.log("Debug: About to query posts for user:", user.id); + console.log("Debug: Author IDs:", authorIds); + console.log("Debug: Beacon enabled:", beaconEnabled); + + // Fetch posts from followed users and self + let postsQuery = serviceClient + .from("posts") + .select(`id, type, body, created_at, visibility, author_id, + author:profiles!posts_author_id_fkey (id, handle, display_name, avatar_url)`) + .in("author_id", authorIds); + // .eq("status", "active"); // Temporarily remove status filter to debug + + // Filter visibility: user can see their own posts (any visibility), + // or public/followers posts from people they follow + postsQuery = postsQuery.or(`author_id.eq.${user.id},visibility.in.(public,followers)`); + + // Only filter out beacons if user has NOT opted in + if (!beaconEnabled) { + postsQuery = postsQuery.eq("is_beacon", false); + } + + const { data: posts, error: postsError } = await postsQuery + .order("created_at", { ascending: false }) + .range(offset, offset + limit - 1); + + if (postsError) { + console.error("Error fetching posts:", postsError); + return new Response(JSON.stringify({ error: "Failed to fetch feed" }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }); + } + + // Get chain parent posts separately (self-referential relationship not set up) + const postsWithChains = posts || []; + const chainParentIds = postsWithChains + .filter((p: any) => p.chain_parent_id) + .map((p: any) => p.chain_parent_id); + + let chainParentMap = new Map(); + if (chainParentIds.length > 0) { + const { data: chainParents } = await serviceClient + .from("posts") + .select(`id, body, created_at, + author:profiles!posts_author_id_fkey (id, handle, display_name, avatar_url)`) + .in("id", [...new Set(chainParentIds)]); + + chainParents?.forEach((cp: any) => { + chainParentMap.set(cp.id, { + id: cp.id, + body: cp.body, + created_at: cp.created_at, + author: cp.author, + }); + }); + } + + const feedItems = postsWithChains.map((post: any) => ({ + id: post.id, body: post.body, body_format: post.body_format, background_id: post.background_id, created_at: post.created_at, tone_label: post.tone_label, + allow_chain: post.allow_chain, chain_parent_id: post.chain_parent_id, + image_url: post.image_url, + visibility: post.visibility, + chain_parent: post.chain_parent_id ? chainParentMap.get(post.chain_parent_id) : null, + author: post.author, category: post.category, metrics: post.metrics, + user_liked: post.user_liked?.some((l: any) => l.user_id === user.id) || false, + user_saved: post.user_saved?.some((s: any) => s.user_id === user.id) || false, + })); + + const signedItems = await Promise.all( + feedItems.map(async (post) => { + if (!post.image_url) { + return post; + } + return { ...post, image_url: await trySignR2Url(post.image_url) }; + }) + ); + + return new Response(JSON.stringify({ posts: signedItems, pagination: { limit, offset, returned: signedItems.length } }), { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }); + } catch (error) { + console.error("Unexpected error:", error); + return new Response(JSON.stringify({ error: "Internal server error" }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }); + } +}); diff --git a/_legacy/supabase/functions/feed-sojorn/config.toml b/_legacy/supabase/functions/feed-sojorn/config.toml new file mode 100644 index 0000000..4d86786 --- /dev/null +++ b/_legacy/supabase/functions/feed-sojorn/config.toml @@ -0,0 +1 @@ +verify_jwt = false diff --git a/_legacy/supabase/functions/feed-sojorn/index.ts b/_legacy/supabase/functions/feed-sojorn/index.ts new file mode 100644 index 0000000..2ef90e0 --- /dev/null +++ b/_legacy/supabase/functions/feed-sojorn/index.ts @@ -0,0 +1,363 @@ +import { serve } from "https://deno.land/std@0.177.0/http/server.ts"; +import { createSupabaseClient, createServiceClient } from "../_shared/supabase-client.ts"; +import { rankPosts, type PostForRanking } from "../_shared/ranking.ts"; +import { trySignR2Url } from "../_shared/r2_signer.ts"; + +const ALLOWED_ORIGIN = Deno.env.get("ALLOWED_ORIGIN") || "*"; +const corsHeaders = { + "Access-Control-Allow-Origin": ALLOWED_ORIGIN, + "Access-Control-Allow-Methods": "GET", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", +}; + +interface Profile { id: string; handle: string; display_name: string; } +interface Category { id: string; slug: string; name: string; } +interface PostMetrics { like_count: number; save_count: number; view_count: number; } +interface Post { + id: string; body: string; body_format?: string; background_id?: string; created_at: string; category_id: string | null; + tone_label: "positive" | "neutral" | "mixed" | "negative"; + cis_score: number; author_id: string; author: Profile; category: Category; + metrics: PostMetrics | null; allow_chain: boolean; chain_parent_id: string | null; + image_url: string | null; tags: string[] | null; visibility?: string; + user_liked: { user_id: string }[]; user_saved: { user_id: string }[]; + // Sponsored ad fields + is_sponsored?: boolean; + advertiser_name?: string; + advertiser_cta_link?: string; + advertiser_cta_text?: string; + advertiser_body?: string; + advertiser_image_url?: string; +} +interface TrustState { user_id: string; harmony_score: number; tier: "new" | "standard" | "trusted"; } +interface Block { blocked_id: string; } +interface Report { target_id: string; reporter_id: string; status: "pending" | "resolved"; } +interface PostLike { user_id: string; } +interface PostSave { user_id: string; } +interface SponsoredPost { + id: string; + advertiser_name: string; + body: string; + image_url: string | null; + cta_link: string; + cta_text: string; +} + +serve(async (req: Request) => { + if (req.method === "OPTIONS") { + return new Response(null, { headers: corsHeaders }); + } + + try { + const authHeader = req.headers.get("Authorization"); + if (!authHeader) { + console.error("Missing authorization header"); + return new Response(JSON.stringify({ error: "Missing authorization header" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }); + } + + console.log("Auth header present, length:", authHeader.length); + + // Extract JWT from header + const jwt = authHeader.replace("Bearer ", ""); + console.log("JWT extracted, length:", jwt.length); + + const supabase = createSupabaseClient(authHeader); + console.log("Supabase client created"); + + const serviceClient = createServiceClient(); + console.log("Service client created"); + + // Don't pass JWT explicitly - let the SDK validate using its internal session + // The auth header was already used to create the client + const { data: { user }, error: authError } = await supabase.auth.getUser(); + console.log("getUser result:", { userId: user?.id, error: authError }); + + if (authError || !user) { + console.error("Auth error in feed-sojorn:", authError); + console.error("User object:", user); + return new Response(JSON.stringify({ error: "Unauthorized", details: authError?.message || "No user returned" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }); + } + + const url = new URL(req.url); + const limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 100); + const offset = parseInt(url.searchParams.get("offset") || "0"); + + // Check if user has opted into beacon posts + const { data: profile } = await serviceClient + .from("profiles") + .select("beacon_enabled") + .eq("id", user.id) + .single(); + + const beaconEnabled = profile?.beacon_enabled || false; + + // Get user's enabled category IDs for ad targeting + const { data: userCategorySettings, error: userCategoryError } = await serviceClient + .from("user_category_settings") + .select("category_id") + .eq("user_id", user.id) + .eq("enabled", true); + + if (userCategoryError) { + console.error("Error fetching user category settings:", userCategoryError); + } + + const userCategoryIds = (userCategorySettings || []) + .map((uc) => uc.category_id) + .filter(Boolean); + + // Map categories to their slugs for tag matching (normalized lowercase) + let userCategorySlugs: string[] = []; + if (userCategoryIds.length > 0) { + const { data: categories } = await serviceClient + .from("categories") + .select("id, slug") + .in("id", userCategoryIds); + + userCategorySlugs = (categories || []) + .map((c: Category) => (c.slug || "").toLowerCase()) + .filter((slug) => slug.length > 0); + } + + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); + + // Fetch posts prioritizing those with images first, then by recency + // This ensures visual content gets featured prominently + // Exclude beacon posts unless user has opted in + let postsQuery = serviceClient + .from("posts") + .select(`id, body, body_format, created_at, category_id, tone_label, cis_score, author_id, image_url, tags, visibility, + author:profiles!posts_author_id_fkey (id, handle, display_name, avatar_url), + category:categories!posts_category_id_fkey (id, slug, name), + metrics:post_metrics (like_count, save_count)`) + .in("tone_label", ["positive", "neutral", "mixed"]) + .gte("created_at", sevenDaysAgo); + + // Hybrid matching: legacy categories OR new hashtag tags + if (userCategoryIds.length > 0 || userCategorySlugs.length > 0) { + const orConditions: string[] = []; + if (userCategoryIds.length > 0) { + orConditions.push(`category_id.in.(${userCategoryIds.join(",")})`); + } + if (userCategorySlugs.length > 0) { + orConditions.push(`tags.ov.{${userCategorySlugs.join(",")}}`); + } + if (orConditions.length > 0) { + postsQuery = postsQuery.or(orConditions.join(",")); + } + } + + // Only filter out beacons if user has NOT opted in + if (!beaconEnabled) { + postsQuery = postsQuery.eq("is_beacon", false); + } + + const { data: posts, error: postsError } = await postsQuery + .order("image_url", { ascending: false }) // Posts WITH images first + .order("created_at", { ascending: false }) + .limit(1000); // Fetch more to rank, then paginate + + if (postsError) { + console.error("Error fetching posts:", postsError); + return new Response(JSON.stringify({ + error: "Failed to fetch feed", + details: postsError.message, + code: postsError.code, + hint: postsError.hint, + }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }); + } + + const safePosts = posts || []; + const authorIds = [...new Set(safePosts.map((p: Post) => p.author_id))]; + const trustStates = authorIds.length > 0 + ? (await serviceClient.from("trust_state").select("user_id, harmony_score, tier").in("user_id", authorIds)).data + : []; + const trustMap = new Map(trustStates?.map((t: TrustState) => [t.user_id, t]) || []); + + const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + const recentBlocks = authorIds.length > 0 + ? (await serviceClient.from("blocks").select("blocked_id").in("blocked_id", authorIds).gte("created_at", oneDayAgo)).data + : []; + const blocksMap = new Map(); + recentBlocks?.forEach((block: Block) => { blocksMap.set(block.blocked_id, (blocksMap.get(block.blocked_id) || 0) + 1); }); + + const postIds = safePosts.map((p: Post) => p.id); + const reports = postIds.length > 0 + ? (await serviceClient.from("reports").select("target_id, reporter_id, status").eq("target_type", "post").in("target_id", postIds)).data + : []; + const trustedReportMap = new Map(); + const totalReportMap = new Map(); + for (const report of reports || []) { + totalReportMap.set(report.target_id, (totalReportMap.get(report.target_id) || 0) + 1); + const reporterTrust = trustMap.get(report.reporter_id); + if (reporterTrust && reporterTrust.harmony_score >= 70) { + trustedReportMap.set(report.target_id, (trustedReportMap.get(report.target_id) || 0) + 1); + } + } + + // Calculate has_image bonus for ranking + const postsForRanking: PostForRanking[] = safePosts.map((post: Post) => { + const authorTrust = trustMap.get(post.author_id); + return { + id: post.id, + created_at: post.created_at, + cis_score: post.cis_score || 0.8, + tone_label: post.tone_label || "neutral", + save_count: post.metrics?.save_count || 0, + like_count: post.metrics?.like_count || 0, + view_count: post.metrics?.view_count || 0, + author_harmony_score: authorTrust?.harmony_score || 50, + author_tier: authorTrust?.tier || "new", + blocks_received_24h: blocksMap.get(post.author_id) || 0, + trusted_reports: trustedReportMap.get(post.id) || 0, + total_reports: totalReportMap.get(post.id) || 0, + has_image: post.image_url != null && post.image_url.length > 0, + }; + }); + + // Use ranking algorithm + const rankedPosts = rankPosts(postsForRanking); + const paginatedPosts = rankedPosts.slice(offset, offset + limit); + const resultIds = paginatedPosts.map((p) => p.id); + + let finalPosts: Post[] = []; + if (resultIds.length > 0) { + const { data, error: finalError } = await serviceClient + .from("posts") + .select(`id, body, body_format, background_id, created_at, tone_label, allow_chain, chain_parent_id, image_url, tags, visibility, + author:profiles!posts_author_id_fkey (id, handle, display_name, avatar_url), + category:categories!posts_category_id_fkey (id, slug, name), + metrics:post_metrics (like_count, save_count), + user_liked:post_likes!left (user_id), + user_saved:post_saves!left (user_id)`) + .in("id", resultIds); + + if (finalError) { + console.error("Error fetching final posts:", finalError); + return new Response(JSON.stringify({ + error: "Failed to fetch feed", + details: finalError.message, + code: finalError.code, + hint: finalError.hint, + }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }); + } + + finalPosts = data || []; + } + + let orderedPosts = resultIds.map((id) => finalPosts.find((p: Post) => p.id === id)).filter(Boolean).map((post: Post) => ({ + id: post.id, body: post.body, body_format: post.body_format, background_id: post.background_id, created_at: post.created_at, tone_label: post.tone_label, allow_chain: post.allow_chain, + chain_parent_id: post.chain_parent_id, image_url: post.image_url, tags: post.tags, visibility: post.visibility, author: post.author, category: post.category, metrics: post.metrics, + user_liked: post.user_liked?.some((l: PostLike) => l.user_id === user.id) || false, + user_saved: post.user_saved?.some((s: PostSave) => s.user_id === user.id) || false, + })); + + // ========================================================================= + // SILENT AD INJECTION - Check for sponsored content + // ========================================================================= + + // Only inject if user has categories and we have posts to inject into + if (userCategoryIds.length > 0 && orderedPosts.length > 0) { + try { + // Fetch a random active ad that matches user's subscribed categories + const { data: sponsoredPosts } = await serviceClient + .from("sponsored_posts") + .select("id, advertiser_name, body, image_url, cta_link, cta_text") + .eq("active", true) + .or( + `target_categories.cs.{*},target_categories.ov.{${userCategoryIds.join(",")}}`, + ) + .lt("current_impressions", serviceClient.raw("impression_goal")) // Only show if under goal + .limit(1); + + if (sponsoredPosts && sponsoredPosts.length > 0) { + const ad = sponsoredPosts[0] as SponsoredPost; + + // Create a fake post object that looks like a real post but is marked as sponsored + const sponsoredPost: Post = { + id: ad.id, + body: ad.body, + body_format: "markdown", + background_id: null, + created_at: new Date().toISOString(), + tone_label: "neutral", + cis_score: 1.0, + author_id: "sponsored", + author: { + id: "sponsored", + handle: "sponsored", + display_name: ad.advertiser_name, + }, + category: { + id: "sponsored", + slug: "sponsored", + name: "Sponsored", + }, + metrics: { like_count: 0, save_count: 0, view_count: 0 }, + allow_chain: false, + chain_parent_id: null, + image_url: ad.image_url, + tags: null, + user_liked: [], + user_saved: [], + // Sponsored ad markers + is_sponsored: true, + advertiser_name: ad.advertiser_name, + advertiser_cta_link: ad.cta_link, + advertiser_cta_text: ad.cta_text, + advertiser_body: ad.body, + advertiser_image_url: ad.image_url, + }; + + // Inject at position 4 (5th slot) if we have enough posts + const adInjectionIndex = 4; + if (orderedPosts.length > adInjectionIndex) { + orderedPosts = [ + ...orderedPosts.slice(0, adInjectionIndex), + sponsoredPost, + ...orderedPosts.slice(adInjectionIndex), + ]; + console.log("Sponsored ad injected at index", adInjectionIndex, "advertiser:", ad.advertiser_name); + } else { + // If not enough posts, inject at the end + orderedPosts = [...orderedPosts, sponsoredPost]; + console.log("Sponsored ad injected at end, advertiser:", ad.advertiser_name); + } + } + } catch (error) { + console.error("Sponsored ad injection failed:", error); + } + } + // ========================================================================= + // END AD INJECTION + // ========================================================================= + + const signedPosts = await Promise.all( + orderedPosts.map(async (post) => { + if (!post.image_url && !post.advertiser_image_url) { + return post; + } + + const nextPost = { ...post }; + if (post.image_url) { + nextPost.image_url = await trySignR2Url(post.image_url); + } + if (post.advertiser_image_url) { + nextPost.advertiser_image_url = await trySignR2Url(post.advertiser_image_url); + } + return nextPost; + }) + ); + + return new Response(JSON.stringify({ + posts: signedPosts, + pagination: { limit, offset, returned: orderedPosts.length }, + ranking_explanation: "Posts are ranked by: 1) Has image bonus, 2) Author harmony score, 3) Steady appreciation (saves > likes), 4) Recency. Sponsored content may appear periodically.", + }), { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }); + } catch (error) { + console.error("Unexpected error:", error); + return new Response(JSON.stringify({ + error: "Internal server error", + details: error instanceof Error ? error.message : String(error), + }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }); + } +}); diff --git a/_legacy/supabase/functions/follow/config.toml b/_legacy/supabase/functions/follow/config.toml new file mode 100644 index 0000000..4d86786 --- /dev/null +++ b/_legacy/supabase/functions/follow/config.toml @@ -0,0 +1 @@ +verify_jwt = false diff --git a/_legacy/supabase/functions/follow/index.ts b/_legacy/supabase/functions/follow/index.ts new file mode 100644 index 0000000..ebbbbdf --- /dev/null +++ b/_legacy/supabase/functions/follow/index.ts @@ -0,0 +1,135 @@ +/** + * POST /follow - Follow a user + * DELETE /follow - Unfollow a user + * + * Design intent: + * - Following is explicit and intentional + * - Mutual follow enables conversation + * - Cannot follow if blocked + */ + +import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'; +import { createSupabaseClient } from '../_shared/supabase-client.ts'; +import { validateUUID, ValidationError } from '../_shared/validation.ts'; + +interface FollowRequest { + user_id: string; // the user to follow/unfollow +} + +serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, DELETE', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', + }, + }); + } + + try { + const authHeader = req.headers.get('Authorization'); + if (!authHeader) { + return new Response(JSON.stringify({ error: 'Missing authorization header' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const supabase = createSupabaseClient(authHeader); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError || !user) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + let { user_id: following_id } = (await req.json()) as FollowRequest; + validateUUID(following_id, 'user_id'); + following_id = following_id.toLowerCase(); + + if (following_id === user.id.toLowerCase()) { + return new Response( + JSON.stringify({ error: 'Cannot follow yourself' }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + + // Handle unfollow + if (req.method === 'DELETE') { + const { error: deleteError } = await supabase + .from('follows') + .delete() + .eq('follower_id', user.id) + .eq('following_id', following_id); + + if (deleteError) { + console.error('Error unfollowing:', deleteError); + return new Response(JSON.stringify({ error: 'Failed to unfollow' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } + + return new Response(JSON.stringify({ success: true, message: 'Unfollowed' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Handle follow (POST) via request_follow + const { data: status, error: followError } = await supabase + .rpc('request_follow', { target_id: following_id }); + + if (followError) { + if (followError.message?.includes('Cannot follow') || followError.code === '23514') { + return new Response( + JSON.stringify({ error: 'Cannot follow this user' }), + { status: 403, headers: { 'Content-Type': 'application/json' } } + ); + } + + console.error('Error following:', followError); + return new Response(JSON.stringify({ error: 'Failed to follow' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const followStatus = status as string | null; + const message = + followStatus === 'pending' + ? 'Request sent.' + : 'Followed. Mutual follow enables conversation.'; + + return new Response( + JSON.stringify({ + success: true, + status: followStatus, + message, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + } catch (error) { + if (error instanceof ValidationError) { + return new Response( + JSON.stringify({ error: 'Validation error', message: error.message }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + + console.error('Unexpected error:', error); + return new Response(JSON.stringify({ error: 'Internal server error' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +}); diff --git a/_legacy/supabase/functions/manage-post/config.toml b/_legacy/supabase/functions/manage-post/config.toml new file mode 100644 index 0000000..4d86786 --- /dev/null +++ b/_legacy/supabase/functions/manage-post/config.toml @@ -0,0 +1 @@ +verify_jwt = false diff --git a/_legacy/supabase/functions/manage-post/index.ts b/_legacy/supabase/functions/manage-post/index.ts new file mode 100644 index 0000000..0a9108d --- /dev/null +++ b/_legacy/supabase/functions/manage-post/index.ts @@ -0,0 +1,318 @@ +/// +import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'; +import { createSupabaseClient, createServiceClient } from '../_shared/supabase-client.ts'; + +const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || 'https://sojorn.net'; + +const corsHeaders = { + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +// ============================================================================ +// TONE CHECK (inline version to avoid external call) +// ============================================================================ + +interface ToneResult { + tone: 'positive' | 'neutral' | 'mixed' | 'negative' | 'hostile' | 'hate'; + cis: number; + flags: string[]; + reason: string; +} + +function basicModeration(text: string): ToneResult { + const lowerText = text.toLowerCase(); + const flags: string[] = []; + + // Slur patterns + const slurPatterns = [/\bn+[i1]+g+[aegr]+/i, /\bf+[a4]+g+[s$o0]+t/i, /\br+[e3]+t+[a4]+r+d/i]; + for (const pattern of slurPatterns) { + if (pattern.test(text)) { + return { tone: 'hate', cis: 0.0, flags: ['hate-speech'], reason: 'Hate speech detected.' }; + } + } + + // Attack patterns + const attackPatterns = [/\b(fuck|screw|damn)\s+(you|u|your|ur)/i, /\b(kill|hurt|attack)\s+(you|yourself)/i]; + for (const pattern of attackPatterns) { + if (pattern.test(text)) { + return { tone: 'hostile', cis: 0.2, flags: ['hostile'], reason: 'Personal attack detected.' }; + } + } + + // Positive indicators + const positiveWords = ['thank', 'appreciate', 'love', 'support', 'grateful']; + if (positiveWords.some(word => lowerText.includes(word))) { + return { tone: 'positive', cis: 1.0, flags: [], reason: 'Positive content' }; + } + + return { tone: 'neutral', cis: 0.8, flags: [], reason: 'Content approved' }; +} + +async function checkTone(text: string): Promise { + const openAiKey = Deno.env.get('OPEN_AI'); + + if (openAiKey) { + try { + const response = await fetch('https://api.openai.com/v1/moderations', { + method: 'POST', + headers: { 'Authorization': `Bearer ${openAiKey}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ input: text, model: 'text-moderation-latest' }), + }); + + if (response.ok) { + const data = await response.json(); + const results = data.results?.[0]; + if (results) { + if (results.flagged) { + if (results.categories?.hate) return { tone: 'hate', cis: 0.0, flags: ['hate'], reason: 'Hate speech detected.' }; + if (results.categories?.harassment) return { tone: 'hostile', cis: 0.2, flags: ['harassment'], reason: 'Harassment detected.' }; + return { tone: 'mixed', cis: 0.5, flags: ['flagged'], reason: 'Content flagged.' }; + } + // Not flagged - return neutral with CIS based on scores + const maxScore = Math.max(results.category_scores?.harassment || 0, results.category_scores?.hate || 0); + if (maxScore > 0.5) return { tone: 'mixed', cis: 0.6, flags: [], reason: 'Content approved (caution)' }; + return { tone: 'neutral', cis: 0.8, flags: [], reason: 'Content approved' }; + } + } + } catch (error) { + console.error('OpenAI moderation error:', error); + } + } + + return basicModeration(text); +} + +// ============================================================================ +// MAIN HANDLER +// ============================================================================ + +interface RequestBody { + action: 'edit' | 'delete' | 'update_privacy' | 'bulk_update_privacy' | 'pin' | 'unpin'; + post_id?: string; + content?: string; // For edit action + visibility?: 'public' | 'followers' | 'private'; +} + +serve(async (req: Request) => { + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: { ...corsHeaders, 'Access-Control-Allow-Methods': 'POST OPTIONS' } }); + } + + try { + const authHeader = req.headers.get('Authorization'); + if (!authHeader) { + return new Response(JSON.stringify({ error: 'Missing authorization' }), { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); + } + + const supabase = createSupabaseClient(authHeader); + const serviceClient = createServiceClient(); + + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); + } + + const { action, post_id, content, visibility } = await req.json() as RequestBody; + + if (!action) { + return new Response(JSON.stringify({ error: 'Missing required fields' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); + } + + const allowedVisibilities = ['public', 'followers', 'private']; + if ((action === 'update_privacy' || action === 'bulk_update_privacy') && (!visibility || !allowedVisibilities.includes(visibility))) { + return new Response(JSON.stringify({ error: 'Invalid visibility' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); + } + + if (action === 'bulk_update_privacy') { + const { error: bulkError } = await serviceClient + .from('posts') + .update({ visibility }) + .eq('author_id', user.id); + + if (bulkError) { + console.error('Bulk privacy update error:', bulkError); + return new Response(JSON.stringify({ error: 'Failed to update post privacy' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); + } + + return new Response(JSON.stringify({ success: true, message: 'Post privacy updated' }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); + } + + if (!post_id) { + return new Response(JSON.stringify({ error: 'Missing post_id' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); + } + + // Fetch the post + const { data: post, error: postError } = await serviceClient + .from('posts') + .select('id, author_id, body, tone_label, cis_score, created_at, is_edited') + .eq('id', post_id) + .single(); + + if (postError || !post) { + return new Response(JSON.stringify({ error: 'Post not found' }), { status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); + } + + // Verify ownership + if (post.author_id !== user.id) { + return new Response(JSON.stringify({ error: 'You can only edit or delete your own posts' }), { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); + } + + // ======================================================================== + // ACTION: PIN + // ======================================================================== + if (action === 'pin') { + const pinnedAt = new Date().toISOString(); + + const { error: clearError } = await serviceClient + .from('posts') + .update({ pinned_at: null }) + .eq('author_id', user.id); + + if (clearError) { + console.error('Clear pinned post error:', clearError); + return new Response(JSON.stringify({ error: 'Failed to pin post' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); + } + + const { error: pinError } = await serviceClient + .from('posts') + .update({ pinned_at: pinnedAt }) + .eq('id', post_id); + + if (pinError) { + console.error('Pin post error:', pinError); + return new Response(JSON.stringify({ error: 'Failed to pin post' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); + } + + return new Response(JSON.stringify({ success: true, message: 'Post pinned' }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); + } + + // ======================================================================== + // ACTION: UNPIN + // ======================================================================== + if (action === 'unpin') { + const { error: unpinError } = await serviceClient + .from('posts') + .update({ pinned_at: null }) + .eq('id', post_id); + + if (unpinError) { + console.error('Unpin post error:', unpinError); + return new Response(JSON.stringify({ error: 'Failed to unpin post' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); + } + + return new Response(JSON.stringify({ success: true, message: 'Post unpinned' }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); + } + + // ======================================================================== + // ACTION: UPDATE PRIVACY + // ======================================================================== + if (action === 'update_privacy') { + const { error: privacyError } = await serviceClient + .from('posts') + .update({ visibility }) + .eq('id', post_id); + + if (privacyError) { + console.error('Privacy update error:', privacyError); + return new Response(JSON.stringify({ error: 'Failed to update post privacy' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); + } + + return new Response(JSON.stringify({ success: true, message: 'Post privacy updated' }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); + } + + // ======================================================================== + // ACTION: DELETE (Hard Delete) + // ======================================================================== + if (action === 'delete') { + const { error: deleteError } = await serviceClient + .from('posts') + .delete() + .eq('id', post_id); + + if (deleteError) { + console.error('Delete error:', deleteError); + return new Response(JSON.stringify({ error: 'Failed to delete post' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); + } + + return new Response(JSON.stringify({ success: true, message: 'Post deleted successfully' }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); + } + + // ======================================================================== + // ACTION: EDIT + // ======================================================================== + if (action === 'edit') { + if (!content || content.trim().length === 0) { + return new Response(JSON.stringify({ error: 'Content is required for edits' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); + } + + // 1. Time Check: 2-minute window + const createdAt = new Date(post.created_at); + const now = new Date(); + const twoMinutesAgo = new Date(now.getTime() - 2 * 60 * 1000); + + if (createdAt < twoMinutesAgo) { + return new Response(JSON.stringify({ error: 'Edit window expired. Posts can only be edited within 2 minutes of creation.' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); + } + + // 2. Check if already edited (one edit only) + if (post.is_edited) { + return new Response(JSON.stringify({ error: 'Post has already been edited' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); + } + + // 3. Moderation Check: Run new content through tone check + const moderation = await checkTone(content); + + if (moderation.tone === 'hate' || moderation.tone === 'hostile') { + return new Response(JSON.stringify({ + error: 'Edit rejected: Content does not meet community guidelines.', + details: moderation.reason + }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); + } + + // 4. Archive: Save current content to post_versions + const { error: archiveError } = await serviceClient + .from('post_versions') + .insert({ + post_id: post_id, + content: post.body, + tone_label: post.tone_label, + cis_score: post.cis_score, + }); + + if (archiveError) { + console.error('Archive error:', archiveError); + return new Response(JSON.stringify({ error: 'Failed to save edit history' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); + } + + // 5. Update: Apply new content + const { error: updateError } = await serviceClient + .from('posts') + .update({ + body: content, + tone_label: moderation.tone, + cis_score: moderation.cis, + is_edited: true, + edited_at: now.toISOString(), + }) + .eq('id', post_id); + + if (updateError) { + console.error('Update error:', updateError); + return new Response(JSON.stringify({ error: 'Failed to update post' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); + } + + return new Response(JSON.stringify({ + success: true, + message: 'Post updated successfully', + moderation: { tone: moderation.tone, cis: moderation.cis } + }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); + } + + return new Response(JSON.stringify({ error: 'Invalid action' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); + + } catch (error) { + console.error('Unexpected error:', error); + return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); + } +}); diff --git a/_legacy/supabase/functions/notifications/config.toml b/_legacy/supabase/functions/notifications/config.toml new file mode 100644 index 0000000..4d86786 --- /dev/null +++ b/_legacy/supabase/functions/notifications/config.toml @@ -0,0 +1 @@ +verify_jwt = false diff --git a/_legacy/supabase/functions/notifications/index.ts b/_legacy/supabase/functions/notifications/index.ts new file mode 100644 index 0000000..d8582f5 --- /dev/null +++ b/_legacy/supabase/functions/notifications/index.ts @@ -0,0 +1,244 @@ +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.3'; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +interface NotificationRow { + id: string; + user_id: string; + type: 'follow' | 'appreciate' | 'comment' | 'chain' | 'mention' | 'follow_request' | 'new_follower' | 'request_accepted'; + actor_id: string; + post_id: string | null; + comment_id: string | null; + metadata: Record; + is_read: boolean; + created_at: string; +} + +interface NotificationResponse extends NotificationRow { + actor: { + id: string; + handle: string; + display_name: string; + avatar_url: string | null; + }; + post?: { + id: string; + body: string; + } | null; +} + +Deno.serve(async (req) => { + // Handle CORS preflight + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeaders }); + } + + try { + const supabaseUrl = Deno.env.get('SUPABASE_URL') ?? ''; + const anonKey = Deno.env.get('SUPABASE_ANON_KEY') ?? ''; + const serviceRoleKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''; + + const supabaseClient = createClient(supabaseUrl, anonKey, { + global: { + headers: { + Authorization: req.headers.get('Authorization') ?? '', + apikey: anonKey, + }, + }, + }); + + const adminClient = createClient(supabaseUrl, serviceRoleKey); + + // Get authenticated user (explicit token from header) + const authHeader = + req.headers.get('Authorization') ?? req.headers.get('authorization') ?? ''; + const token = authHeader.startsWith('Bearer ') + ? authHeader.slice('Bearer '.length) + : authHeader; + const { + data: { user }, + error: authError, + } = await supabaseClient.auth.getUser(token || undefined); + + if (authError || !user) { + console.error('Auth error:', authError?.message ?? 'No user found'); + console.error('Auth header present:', !!authHeader, 'Length:', authHeader?.length ?? 0); + return new Response(JSON.stringify({ + error: 'Unauthorized', + code: 401, + message: authError?.message ?? 'Invalid JWT' + }), { + status: 401, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + const url = new URL(req.url); + const limit = parseInt(url.searchParams.get('limit') || '20'); + const offset = parseInt(url.searchParams.get('offset') || '0'); + const unreadOnly = url.searchParams.get('unread_only') === 'true'; + const includeArchived = url.searchParams.get('include_archived') === 'true'; + + // Handle different HTTP methods + if (req.method === 'GET') { + // Build query + let query = adminClient + .from('notifications') + .select(` + id, + user_id, + type, + actor_id, + post_id, + comment_id, + metadata, + is_read, + archived_at, + created_at, + actor:actor_id ( + id, + handle, + display_name, + avatar_url + ), + post:post_id ( + id, + body + ) + `) + .eq('user_id', user.id) + .order('created_at', { ascending: false }) + .range(offset, offset + limit - 1); + + if (!includeArchived) { + query = query.is('archived_at', null); + } + + if (unreadOnly) { + query = query.eq('is_read', false); + } + + const { data: notifications, error } = await query; + + if (error) { + return new Response(JSON.stringify({ error: error.message }), { + status: 400, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + return new Response(JSON.stringify({ notifications }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + if (req.method === 'PATCH' || req.method === 'POST') { + // Mark notifications as read + const body = await req.json(); + const { notification_ids, mark_all_read, archive_ids, archive_all } = body; + const archiveAt = new Date().toISOString(); + let didAction = false; + + if (archive_all) { + didAction = true; + const { error } = await adminClient + .from('notifications') + .update({ archived_at: archiveAt, is_read: true }) + .eq('user_id', user.id) + .is('archived_at', null); + + if (error) { + return new Response(JSON.stringify({ error: error.message }), { + status: 400, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + } + + if (archive_ids && Array.isArray(archive_ids)) { + didAction = true; + const { error } = await adminClient + .from('notifications') + .update({ archived_at: archiveAt, is_read: true }) + .in('id', archive_ids) + .eq('user_id', user.id); + + if (error) { + return new Response(JSON.stringify({ error: error.message }), { + status: 400, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + } + + if (mark_all_read) { + didAction = true; + // Mark all notifications as read + const { error } = await adminClient + .from('notifications') + .update({ is_read: true }) + .eq('user_id', user.id) + .eq('is_read', false) + .is('archived_at', null); + + if (error) { + return new Response(JSON.stringify({ error: error.message }), { + status: 400, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + return new Response(JSON.stringify({ success: true }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + if (notification_ids && Array.isArray(notification_ids)) { + didAction = true; + // Mark specific notifications as read + const { error } = await adminClient + .from('notifications') + .update({ is_read: true }) + .in('id', notification_ids) + .eq('user_id', user.id) + .is('archived_at', null); + + if (error) { + return new Response(JSON.stringify({ error: error.message }), { + status: 400, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + return new Response(JSON.stringify({ success: true }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + if (!didAction) { + return new Response(JSON.stringify({ error: 'Invalid request body' }), { + status: 400, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + return new Response(JSON.stringify({ success: true }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + // Method not allowed + return new Response(JSON.stringify({ error: 'Method not allowed' }), { + status: 405, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } catch (error) { + return new Response(JSON.stringify({ error: error.message }), { + status: 500, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } +}); diff --git a/_legacy/supabase/functions/profile-posts/config.toml b/_legacy/supabase/functions/profile-posts/config.toml new file mode 100644 index 0000000..4d86786 --- /dev/null +++ b/_legacy/supabase/functions/profile-posts/config.toml @@ -0,0 +1 @@ +verify_jwt = false diff --git a/_legacy/supabase/functions/profile-posts/index.ts b/_legacy/supabase/functions/profile-posts/index.ts new file mode 100644 index 0000000..6637478 --- /dev/null +++ b/_legacy/supabase/functions/profile-posts/index.ts @@ -0,0 +1,193 @@ +import { serve } from "https://deno.land/std@0.177.0/http/server.ts"; +import { createSupabaseClient, createServiceClient } from "../_shared/supabase-client.ts"; +import { trySignR2Url } from "../_shared/r2_signer.ts"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", +}; + +const postSelect = ` + id, + body, + author_id, + category_id, + tags, + body_format, + background_id, + tone_label, + cis_score, + status, + created_at, + edited_at, + expires_at, + is_edited, + allow_chain, + chain_parent_id, + image_url, + video_url, + thumbnail_url, + duration_ms, + type, + visibility, + pinned_at, + chain_parent:posts ( + id, + body, + created_at, + author:profiles!posts_author_id_fkey ( + id, + handle, + display_name + ) + ), + metrics:post_metrics!post_metrics_post_id_fkey ( + like_count, + save_count, + view_count + ), + author:profiles!posts_author_id_fkey ( + id, + handle, + display_name, + trust_state ( + user_id, + harmony_score, + tier, + posts_today + ) + ) +`; + +serve(async (req: Request) => { + if (req.method === "OPTIONS") { + return new Response(null, { headers: corsHeaders }); + } + + try { + const authHeader = req.headers.get("Authorization"); + if (!authHeader) { + return new Response(JSON.stringify({ error: "Missing authorization header" }), { + status: 401, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + const supabase = createSupabaseClient(authHeader); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError || !user) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + const url = new URL(req.url); + const authorId = url.searchParams.get("author_id"); + if (!authorId) { + return new Response(JSON.stringify({ error: "Missing author_id" }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + const limit = Math.min(parseInt(url.searchParams.get("limit") || "20"), 100); + const offset = parseInt(url.searchParams.get("offset") || "0"); + + // Use service client to bypass RLS issues + const serviceClient = createServiceClient(); + + // Check visibility rules manually: + // - User can always see their own posts + // - Public posts are visible to everyone + // - Followers-only posts require accepted follow + // - Private posts only visible to author + const isOwnProfile = authorId === user.id; + + // Get author's profile to check privacy settings + const { data: authorProfile } = await serviceClient + .from("profiles") + .select("is_private, is_official") + .eq("id", authorId) + .single(); + + // Check if viewer follows the author (for followers-only posts) + let hasAcceptedFollow = false; + if (!isOwnProfile) { + const { data: followRow } = await serviceClient + .from("follows") + .select("status") + .eq("follower_id", user.id) + .eq("following_id", authorId) + .eq("status", "accepted") + .maybeSingle(); + hasAcceptedFollow = !!followRow; + } + + // Build query with appropriate visibility filter + // Note: posts use 'active' status for published posts + let query = serviceClient + .from("posts") + .select(postSelect) + .eq("author_id", authorId) + .eq("status", "active"); + + // Apply visibility filters based on relationship + if (isOwnProfile) { + // User can see all their own posts (no visibility filter) + } else if (hasAcceptedFollow) { + // Follower can see public and followers-only posts + query = query.in("visibility", ["public", "followers"]); + } else if (authorProfile?.is_official || authorProfile?.is_private === false) { + // Public/official profiles - only public posts + query = query.eq("visibility", "public"); + } else { + // Private profile without follow - only public posts + query = query.eq("visibility", "public"); + } + + const { data: posts, error } = await query + .order("pinned_at", { ascending: false, nullsFirst: false }) + .order("created_at", { ascending: false }) + .range(offset, offset + limit - 1); + + if (error) { + console.error("Profile posts fetch error:", error); + return new Response(JSON.stringify({ error: "Failed to fetch posts" }), { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + const signedPosts = await Promise.all( + (posts || []).map(async (post: any) => { + const imageUrl = post.image_url ? await trySignR2Url(post.image_url) : null; + const thumbUrl = post.thumbnail_url ? await trySignR2Url(post.thumbnail_url) : null; + return { + ...post, + image_url: imageUrl, + thumbnail_url: thumbUrl, + }; + }) + ); + + return new Response( + JSON.stringify({ + posts: signedPosts, + pagination: { limit, offset, returned: signedPosts.length }, + }), + { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } catch (error) { + console.error("Unexpected profile-posts error:", error); + return new Response(JSON.stringify({ error: "Internal server error" }), { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } +}); diff --git a/_legacy/supabase/functions/profile/config.toml b/_legacy/supabase/functions/profile/config.toml new file mode 100644 index 0000000..4d86786 --- /dev/null +++ b/_legacy/supabase/functions/profile/config.toml @@ -0,0 +1 @@ +verify_jwt = false diff --git a/_legacy/supabase/functions/profile/index.ts b/_legacy/supabase/functions/profile/index.ts new file mode 100644 index 0000000..6291978 --- /dev/null +++ b/_legacy/supabase/functions/profile/index.ts @@ -0,0 +1,441 @@ +/** + * GET /profile/:handle - Get user profile by handle + * GET /profile/me - Get own profile + * PATCH /profile - Update own profile + * + * Design intent: + * - Profiles are public (unless blocked) + * - Shows harmony tier (not score) + * - Minimal public metrics + */ + +import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'; +import { createSupabaseClient } from '../_shared/supabase-client.ts'; +import { ValidationError } from '../_shared/validation.ts'; +import { trySignR2Url } from '../_shared/r2_signer.ts'; + +const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || '*'; +const corsHeaders = { + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + 'Access-Control-Allow-Methods': 'GET, PATCH', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { + headers: corsHeaders, + }); + } + + try { + const authHeader = req.headers.get('Authorization'); + if (!authHeader) { + return new Response(JSON.stringify({ error: 'Missing authorization header' }), { + status: 401, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + const supabase = createSupabaseClient(authHeader); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError || !user) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + const url = new URL(req.url); + const handle = url.searchParams.get('handle'); + + // GET /profile/me or /profile?handle=username + if (req.method === 'GET') { + let profileQuery; + let isOwnProfile = false; + + if (handle) { + // Get profile by handle + profileQuery = supabase + .from('profiles') + .select( + ` + id, + handle, + display_name, + bio, + location, + website, + interests, + avatar_url, + cover_url, + origin_country, + is_private, + is_official, + created_at, + trust_state ( + tier + ) + ` + ) + .eq('handle', handle) + .single(); + } else { + // Get own profile + isOwnProfile = true; + profileQuery = supabase + .from('profiles') + .select( + ` + id, + handle, + display_name, + bio, + location, + website, + interests, + avatar_url, + cover_url, + origin_country, + is_private, + is_official, + created_at, + updated_at, + trust_state ( + harmony_score, + tier, + posts_today + ) + ` + ) + .eq('id', user.id) + .single(); + } + + const { data: profile, error: profileError } = await profileQuery; + + if (profileError || !profile) { + return new Response(JSON.stringify({ error: 'Profile not found' }), { + status: 404, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + // Check if viewing own profile + if (profile.id === user.id) { + isOwnProfile = true; + } + + // Get privacy settings for this profile + const { data: privacySettings } = await supabase + .from('profile_privacy_settings') + .select('*') + .eq('user_id', profile.id) + .maybeSingle(); + + const profileVisibility = privacySettings?.profile_visibility || 'public'; + + // Apply privacy filtering based on viewer relationship + if (!isOwnProfile && profileVisibility !== 'public') { + // Check if viewer is following the profile + const { data: followData } = await supabase + .from('follows') + .select('status') + .eq('follower_id', user.id) + .eq('following_id', profile.id) + .maybeSingle(); + + const followStatus = followData?.status as string | null; + const isFollowing = followStatus === 'accepted'; + let isFollowedBy = false; + if (user.id !== profile.id) { + const { data: reverseFollow } = await supabase + .from('follows') + .select('status') + .eq('follower_id', profile.id) + .eq('following_id', user.id) + .maybeSingle(); + isFollowedBy = reverseFollow?.status === 'accepted'; + } + const isFriend = isFollowing && isFollowedBy; + + // Check privacy visibility + if (profile.is_private || profileVisibility === 'private') { + // Private profiles show minimal info to non-followers + if (!isFollowing) { + return new Response( + JSON.stringify({ + profile: { + id: profile.id, + handle: profile.handle ?? 'unknown', + display_name: profile.display_name ?? 'Anonymous', + avatar_url: profile.avatar_url, + created_at: profile.created_at, + trust_state: profile.trust_state, + }, + stats: { + posts: 0, + followers: 0, + following: 0, + }, + is_following: false, + is_followed_by: isFollowedBy, + is_friend: isFriend, + follow_status: followStatus, + is_private: true, + }), + { + status: 200, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + } + ); + } + } else if (profileVisibility === 'followers') { + // Followers-only profiles hide details from non-followers + if (!isFollowing) { + profile.bio = null; + profile.location = null; + profile.website = null; + profile.interests = null; + } + } + } + + // Coalesce null values for older clients + const safeProfile = { + ...profile, + handle: profile.handle ?? 'unknown', + display_name: profile.display_name ?? 'Anonymous', + }; + + if (safeProfile.avatar_url) { + safeProfile.avatar_url = await trySignR2Url(safeProfile.avatar_url); + } + if (safeProfile.cover_url) { + safeProfile.cover_url = await trySignR2Url(safeProfile.cover_url); + } + + // Get post count + const { count: postCount } = await supabase + .from('posts') + .select('*', { count: 'exact', head: true }) + .eq('author_id', safeProfile.id); + + // Get follower/following counts + const { count: followerCount } = await supabase + .from('follows') + .select('*', { count: 'exact', head: true }) + .eq('following_id', safeProfile.id) + .eq('status', 'accepted'); + + const { count: followingCount } = await supabase + .from('follows') + .select('*', { count: 'exact', head: true }) + .eq('follower_id', safeProfile.id) + .eq('status', 'accepted'); + + // Check if current user is following this profile + let isFollowing = false; + let isFollowedBy = false; + let isFriend = false; + let followStatus: string | null = null; + if (user && user.id !== safeProfile.id) { + const { data: followData } = await supabase + .from('follows') + .select('status') + .eq('follower_id', user.id) + .eq('following_id', safeProfile.id) + .maybeSingle(); + + followStatus = followData?.status as string | null; + isFollowing = followStatus === 'accepted'; + + const { data: reverseFollow } = await supabase + .from('follows') + .select('status') + .eq('follower_id', safeProfile.id) + .eq('following_id', user.id) + .maybeSingle(); + isFollowedBy = reverseFollow?.status === 'accepted'; + isFriend = isFollowing && isFollowedBy; + } + + const isPrivateForViewer = (profile.is_private ?? false) && !isFollowing && !isOwnProfile; + + return new Response( + JSON.stringify({ + profile: safeProfile, + stats: { + posts: postCount || 0, + followers: followerCount || 0, + following: followingCount || 0, + }, + is_following: isFollowing, + is_followed_by: isFollowedBy, + is_friend: isFriend, + follow_status: followStatus, + is_private: isPrivateForViewer, + }), + { + status: 200, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + } + ); + } + + // PATCH /profile - Update own profile + if (req.method === 'PATCH') { + const { handle, display_name, bio, location, website, interests, avatar_url, cover_url } = await req.json(); + + const updates: any = {}; + + // Handle username changes with 30-day limit + if (handle !== undefined) { + if (handle.trim().length < 3 || handle.length > 20) { + throw new ValidationError('Username must be 3-20 characters', 'handle'); + } + if (!/^[a-zA-Z0-9_]+$/.test(handle)) { + throw new ValidationError('Username can only contain letters, numbers, and underscores', 'handle'); + } + + // Check if handle is already taken + const { data: existingProfile } = await supabase + .from('profiles') + .select('id') + .eq('handle', handle) + .neq('id', user.id) + .maybeSingle(); + + if (existingProfile) { + throw new ValidationError('Username is already taken', 'handle'); + } + + // Check 30-day limit + const { data: canChange, error: canChangeError } = await supabase + .rpc('can_change_handle', { p_user_id: user.id }); + + if (canChangeError || !canChange) { + throw new ValidationError('You can only change your username once every 30 days', 'handle'); + } + + updates.handle = handle; + } + + if (display_name !== undefined) { + if (display_name.trim().length === 0 || display_name.length > 50) { + throw new ValidationError('Display name must be 1-50 characters', 'display_name'); + } + updates.display_name = display_name; + } + + if (bio !== undefined) { + if (bio && bio.length > 300) { + throw new ValidationError('Bio must be 300 characters or less', 'bio'); + } + updates.bio = bio || null; + } + + if (location !== undefined) { + if (location && location.length > 100) { + throw new ValidationError('Location must be 100 characters or less', 'location'); + } + updates.location = location || null; + } + + if (website !== undefined) { + if (website) { + if (website.length > 200) { + throw new ValidationError('Website must be 200 characters or less', 'website'); + } + // Validate URL format and scheme + try { + const url = new URL(website.startsWith('http') ? website : `https://${website}`); + if (!['http:', 'https:'].includes(url.protocol)) { + throw new ValidationError('Website must be a valid HTTP or HTTPS URL', 'website'); + } + } catch (error) { + throw new ValidationError('Website must be a valid URL', 'website'); + } + } + updates.website = website || null; + } + + if (interests !== undefined) { + if (Array.isArray(interests)) { + updates.interests = interests; + } else { + throw new ValidationError('Interests must be an array', 'interests'); + } + } + + if (avatar_url !== undefined) { + updates.avatar_url = avatar_url || null; + } + + if (cover_url !== undefined) { + updates.cover_url = cover_url || null; + } + + if (Object.keys(updates).length === 0) { + return new Response(JSON.stringify({ error: 'No fields to update' }), { + status: 400, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + updates.updated_at = new Date().toISOString(); + + const { data: profile, error: updateError } = await supabase + .from('profiles') + .update(updates) + .eq('id', user.id) + .select() + .single(); + + if (updateError) { + console.error('Error updating profile:', updateError); + return new Response(JSON.stringify({ + error: 'Failed to update profile', + details: updateError.message, + code: updateError.code + }), { + status: 500, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + return new Response( + JSON.stringify({ + profile, + message: 'Profile updated', + }), + { + status: 200, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + } + ); + } + + return new Response(JSON.stringify({ error: 'Method not allowed' }), { + status: 405, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } catch (error) { + if (error instanceof ValidationError) { + return new Response( + JSON.stringify({ error: 'Validation error', message: error.message }), + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + console.error('Unexpected error:', error); + return new Response(JSON.stringify({ error: 'Internal server error' }), { + status: 500, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } +}); diff --git a/_legacy/supabase/functions/publish-comment/config.toml b/_legacy/supabase/functions/publish-comment/config.toml new file mode 100644 index 0000000..4d86786 --- /dev/null +++ b/_legacy/supabase/functions/publish-comment/config.toml @@ -0,0 +1 @@ +verify_jwt = false diff --git a/_legacy/supabase/functions/publish-comment/index.ts b/_legacy/supabase/functions/publish-comment/index.ts new file mode 100644 index 0000000..ca1ac5d --- /dev/null +++ b/_legacy/supabase/functions/publish-comment/index.ts @@ -0,0 +1,206 @@ +/** + * POST /publish-comment + * + * Design intent: + * - Conversation requires consent (mutual follow). + * - Sharp speech is rejected quietly. + * - Comments never affect post reach. + * + * Flow: + * 1. Validate auth and inputs + * 2. Verify mutual follow with post author + * 3. Reject profanity or hostility + * 4. Store comment with tone metadata + * 5. Log audit event + */ + +import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'; +import { createSupabaseClient, createServiceClient } from '../_shared/supabase-client.ts'; +import { analyzeTone, getRewriteSuggestion } from '../_shared/tone-detection.ts'; +import { validateCommentBody, validateUUID, ValidationError } from '../_shared/validation.ts'; + +interface PublishCommentRequest { + post_id: string; + body: string; +} + +serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', + }, + }); + } + + try { + // 1. Validate auth + const authHeader = req.headers.get('Authorization'); + if (!authHeader) { + return new Response(JSON.stringify({ error: 'Missing authorization header' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const supabase = createSupabaseClient(authHeader); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError || !user) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // 2. Parse request + const { post_id, body } = (await req.json()) as PublishCommentRequest; + + // 3. Validate inputs + validateUUID(post_id, 'post_id'); + validateCommentBody(body); + + // 4. Get post author + const { data: post, error: postError } = await supabase + .from('posts') + .select('author_id') + .eq('id', post_id) + .single(); + + if (postError || !post) { + return new Response( + JSON.stringify({ + error: 'Post not found', + message: 'This post does not exist or you cannot see it.', + }), + { + status: 404, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + // 5. Verify mutual follow + const serviceClient = createServiceClient(); + const { data: isMutual, error: followError } = await serviceClient.rpc('is_mutual_follow', { + user_a: user.id, + user_b: post.author_id, + }); + + if (followError) { + console.error('Error checking mutual follow:', followError); + return new Response(JSON.stringify({ error: 'Failed to verify follow relationship' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (!isMutual) { + return new Response( + JSON.stringify({ + error: 'Mutual follow required', + message: 'You can only comment on posts from people you mutually follow.', + }), + { + status: 403, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + // 6. Analyze tone + const analysis = analyzeTone(body); + + // 7. Reject hostile or profane content + if (analysis.shouldReject) { + await serviceClient.rpc('log_audit_event', { + p_actor_id: user.id, + p_event_type: 'comment_rejected', + p_payload: { + post_id, + tone: analysis.tone, + cis: analysis.cis, + flags: analysis.flags, + reason: analysis.rejectReason, + }, + }); + + return new Response( + JSON.stringify({ + error: 'Comment rejected', + message: analysis.rejectReason, + suggestion: getRewriteSuggestion(analysis), + tone: analysis.tone, + }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + // 8. Create comment + const { data: comment, error: commentError } = await supabase + .from('comments') + .insert({ + post_id, + author_id: user.id, + body, + tone_label: analysis.tone, + status: 'active', + }) + .select() + .single(); + + if (commentError) { + console.error('Error creating comment:', commentError); + return new Response(JSON.stringify({ error: 'Failed to create comment' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // 9. Log successful comment + await serviceClient.rpc('log_audit_event', { + p_actor_id: user.id, + p_event_type: 'comment_created', + p_payload: { + comment_id: comment.id, + post_id, + tone: analysis.tone, + cis: analysis.cis, + }, + }); + + // 10. Return comment + return new Response(JSON.stringify({ comment }), { + status: 201, + headers: { 'Content-Type': 'application/json' }, + }); + } catch (error) { + if (error instanceof ValidationError) { + return new Response( + JSON.stringify({ + error: 'Validation error', + message: error.message, + field: error.field, + }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + console.error('Unexpected error:', error); + return new Response(JSON.stringify({ error: 'Internal server error' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +}); diff --git a/_legacy/supabase/functions/publish-post/config.toml b/_legacy/supabase/functions/publish-post/config.toml new file mode 100644 index 0000000..4d86786 --- /dev/null +++ b/_legacy/supabase/functions/publish-post/config.toml @@ -0,0 +1 @@ +verify_jwt = false diff --git a/_legacy/supabase/functions/publish-post/index.ts b/_legacy/supabase/functions/publish-post/index.ts new file mode 100644 index 0000000..a8feebb --- /dev/null +++ b/_legacy/supabase/functions/publish-post/index.ts @@ -0,0 +1,629 @@ +/** + * POST /publish-post + * + * Features: + * - Hashtag extraction and storage + * - AI tone analysis + * - Rate limiting via trust state + * - Beacon support (location-based alerts) + */ + +import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'; +import { createSupabaseClient, createServiceClient } from '../_shared/supabase-client.ts'; +import { validatePostBody, validateUUID, ValidationError } from '../_shared/validation.ts'; +import { trySignR2Url } from '../_shared/r2_signer.ts'; + +interface PublishPostRequest { + category_id?: string | null; + body: string; + body_format?: 'plain' | 'markdown'; + allow_chain?: boolean; + chain_parent_id?: string | null; + chain_parent_id?: string | null; + image_url?: string | null; + thumbnail_url?: string | null; + ttl_hours?: number | null; + user_warned?: boolean; + + // Beacon fields (optional) + is_beacon?: boolean; + beacon_type?: 'police' | 'checkpoint' | 'taskForce' | 'hazard' | 'safety' | 'community'; + beacon_lat?: number; + beacon_long?: number; +} + +interface AnalysisResult { + flagged: boolean; + category?: 'bigotry' | 'nsfw' | 'violence'; + flags: string[]; + rejectReason?: string; +} + +const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || '*'; +const CORS_HEADERS = { + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + 'Access-Control-Allow-Methods': 'POST', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +type ModerationStatus = 'approved' | 'flagged_bigotry' | 'flagged_nsfw' | 'rejected'; + +function getModerationStatus(category?: AnalysisResult['category']): ModerationStatus { + switch (category) { + case 'bigotry': + return 'flagged_bigotry'; + case 'nsfw': + return 'flagged_nsfw'; + case 'violence': + return 'rejected'; + default: + return 'approved'; + } +} + +/** + * Extract hashtags from post body using regex + * Returns array of lowercase tags without the # prefix + */ +function extractHashtags(body: string): string[] { + const hashtagRegex = /#\w+/g; + const matches = body.match(hashtagRegex); + if (!matches) return []; + + // Remove # prefix and lowercase all tags + return [...new Set(matches.map(tag => tag.substring(1).toLowerCase()))]; +} + +serve(async (req: Request) => { + // CORS preflight + if (req.method === 'OPTIONS') { + return new Response(null, { headers: CORS_HEADERS }); + } + + try { + // 1. Validate auth + const authHeader = req.headers.get('Authorization'); + if (!authHeader) { + return new Response(JSON.stringify({ error: 'Missing authorization header' }), { + status: 401, + headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, + }); + } + + const supabase = createSupabaseClient(authHeader); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError || !user) { + console.error('Auth error details:', { + error: authError, + errorMessage: authError?.message, + errorStatus: authError?.status, + user: user, + authHeader: authHeader ? 'present' : 'missing', + }); + return new Response(JSON.stringify({ + error: 'Unauthorized', + details: authError?.message, + }), { + status: 401, + headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, + }); + } + + const { data: profileRows, error: profileError } = await supabase + .from('profiles') + .select('id') + .eq('id', user.id) + .limit(1); + + if (profileError) { + console.error('Error checking profile:', profileError); + return new Response(JSON.stringify({ error: 'Failed to verify profile' }), { + status: 500, + headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, + }); + } + + if (!profileRows || profileRows.length === 0) { + return new Response( + JSON.stringify({ + error: 'Profile not found', + message: 'Please complete your profile before posting.', + }), + { + status: 400, + headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, + } + ); + } + + // 2. Parse request + const { + category_id, + body, + body_format, + allow_chain, + chain_parent_id, + image_url, + thumbnail_url, + ttl_hours, + user_warned, + is_beacon, + beacon_type, + beacon_lat, + beacon_long, + } = (await req.json()) as PublishPostRequest; + const requestedCategoryId = category_id ?? null; + + // 3. Validate inputs + // For beacons, category_id is ignored and replaced by "Beacon Alerts" internally + validatePostBody(body); + if (is_beacon !== true && category_id) { + validateUUID(category_id, 'category_id'); + } + if (chain_parent_id) { + validateUUID(chain_parent_id, 'chain_parent_id'); + } + + let ttlHours: number | null | undefined = undefined; + if (ttl_hours !== undefined && ttl_hours !== null) { + const parsedTtl = Number(ttl_hours); + if (!Number.isFinite(parsedTtl) || !Number.isInteger(parsedTtl) || parsedTtl < 0) { + return new Response( + JSON.stringify({ + error: 'Validation error', + message: 'ttl_hours must be a non-negative integer', + }), + { + status: 400, + headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, + } + ); + } + ttlHours = parsedTtl; + } + + // Validate beacon fields if provided + if (is_beacon === true) { + const latMissing = beacon_lat === undefined || beacon_lat === null || Number.isNaN(beacon_lat); + const longMissing = beacon_long === undefined || beacon_long === null || Number.isNaN(beacon_long); + + if (latMissing || longMissing) { + return new Response( + JSON.stringify({ + error: 'Validation error', + message: 'beacon_lat and beacon_long are required for beacon posts', + }), + { + status: 400, + headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, + } + ); + } + + const validBeaconTypes = ['police', 'checkpoint', 'taskForce', 'hazard', 'safety', 'community']; + if (beacon_type && !validBeaconTypes.includes(beacon_type)) { + return new Response( + JSON.stringify({ + error: 'Validation error', + message: 'Invalid beacon_type. Must be: police, checkpoint, taskForce, hazard, safety, or community', + }), + { + status: 400, + headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, + } + ); + } + } + + // 4. Check if user can post (rate limiting via trust state) + const serviceClient = createServiceClient(); + const { data: canPostData, error: canPostError } = await serviceClient.rpc('can_post', { + p_user_id: user.id, + }); + + if (canPostError) { + console.error('Error checking post eligibility:', canPostError); + return new Response(JSON.stringify({ error: 'Failed to check posting eligibility' }), { + status: 500, + headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, + }); + } + + if (!canPostData) { + const { data: limitData } = await serviceClient.rpc('get_post_rate_limit', { + p_user_id: user.id, + }); + + return new Response( + JSON.stringify({ + error: 'Rate limit reached', + message: `You have reached your posting limit for today (${limitData} posts).`, + suggestion: 'Take a moment. Your influence grows with patience.', + }), + { + status: 429, + headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, + } + ); + } + + // 5. Validate chain parent (if any) + if (chain_parent_id) { + const { data: parentPost, error: parentError } = await supabase + .from('posts') + .select('id, allow_chain, status') + .eq('id', chain_parent_id) + .single(); + + if (parentError || !parentPost) { + return new Response( + JSON.stringify({ + error: 'Chain unavailable', + message: 'This post is not available for chaining.', + }), + { + status: 400, + headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, + } + ); + } + + if (!parentPost.allow_chain || parentPost.status !== 'active') { + return new Response( + JSON.stringify({ + error: 'Chain unavailable', + message: 'Chaining has been disabled for this post.', + }), + { + status: 400, + headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, + } + ); + } + } + + // 6. Call tone-check function for AI moderation + let analysis: AnalysisResult = { + flagged: false, + category: undefined, + flags: [], + rejectReason: undefined, + }; + + try { + const supabaseUrl = Deno.env.get('SUPABASE_URL') || ''; + const supabaseKey = Deno.env.get('SUPABASE_ANON_KEY') || ''; + + console.log('Calling tone-check function...'); + + const moderationResponse = await fetch( + `${supabaseUrl}/functions/v1/tone-check`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${authHeader}`, + 'Content-Type': 'application/json', + 'apikey': supabaseKey, + }, + body: JSON.stringify({ + text: body, + imageUrl: image_url || undefined, + }), + } + ); + + console.log('tone-check response status:', moderationResponse.status); + + if (moderationResponse.ok) { + const data = await moderationResponse.json(); + console.log('tone-check response:', JSON.stringify(data)); + + const flagged = Boolean(data.flagged); + const category = data.category as AnalysisResult['category'] | undefined; + + analysis = { + flagged, + category, + flags: data.flags || [], + rejectReason: data.reason, + }; + + console.log('Analysis result:', JSON.stringify(analysis)); + } else { + console.error('tone-check failed:', await moderationResponse.text()); + } + } catch (e) { + console.error('Tone check error:', e); + // Fail CLOSED: Reject post if moderation is unavailable + await serviceClient.rpc('log_audit_event', { + p_actor_id: user.id, + p_event_type: 'post_rejected_moderation_error', + p_payload: { + category_id: requestedCategoryId, + error: e.toString(), + }, + }); + + return new Response( + JSON.stringify({ + error: 'Moderation unavailable', + message: 'Content moderation is temporarily unavailable. Please try again later.', + }), + { + status: 503, + headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, + } + ); + } + + // 7. Reject hostile or hateful content + if (analysis.flagged) { + const moderationStatus = getModerationStatus(analysis.category); + if (user_warned === true) { + const { data: profileRow } = await serviceClient + .from('profiles') + .select('strikes') + .eq('id', user.id) + .maybeSingle(); + const currentStrikes = typeof profileRow?.strikes === 'number' ? profileRow!.strikes : 0; + + await serviceClient + .from('profiles') + .update({ strikes: currentStrikes + 1 }) + .eq('id', user.id); + } + + await serviceClient.rpc('log_audit_event', { + p_actor_id: user.id, + p_event_type: 'post_rejected', + p_payload: { + category_id: requestedCategoryId, + moderation_category: analysis.category, + flags: analysis.flags, + reason: analysis.rejectReason, + moderation_status: moderationStatus, + user_warned: user_warned === true, + }, + }); + + return new Response( + JSON.stringify({ + error: 'Content rejected', + message: analysis.rejectReason || 'This content was rejected by moderation.', + category: analysis.category, + moderation_status: moderationStatus, + }), + { + status: 400, + headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, + } + ); + } + + // 8. Determine post status based on tone + const status = 'active'; + const moderationStatus: ModerationStatus = 'approved'; + const toneLabel = 'neutral'; + const cisScore = 0.8; + + // 9. Extract hashtags from body (skip for beacons - they use description as body) + const tags = is_beacon === true ? [] : extractHashtags(body); + console.log(`Extracted ${tags.length} tags:`, tags); + + // 10. Handle beacon category and data + let postCategoryId = requestedCategoryId; + let beaconData: any = null; + + if (is_beacon === true) { + // Get or create "Beacon Alerts" category for beacons + const { data: beaconCategory } = await serviceClient + .from('categories') + .select('id') + .eq('name', 'Beacon Alerts') + .single(); + + if (beaconCategory) { + postCategoryId = beaconCategory.id; + } else { + // Create the beacon category if it doesn't exist + const { data: newCategory } = await serviceClient + .from('categories') + .insert({ name: 'Beacon Alerts', description: 'Community safety and alert posts' }) + .select('id') + .single(); + + if (newCategory) { + postCategoryId = newCategory.id; + } + } + + // Get user's trust score for initial confidence + const { data: profile } = await serviceClient + .from('profiles') + .select('trust_state(harmony_score)') + .eq('id', user.id) + .single(); + + const trustScore = profile?.trust_state?.harmony_score ?? 0.5; + const initialConfidence = 0.5 + (trustScore * 0.3); + + // Store beacon data to be included in post + beaconData = { + is_beacon: true, + beacon_type: beacon_type ?? 'community', + location: `SRID=4326;POINT(${beacon_long} ${beacon_lat})`, + confidence_score: Math.min(1.0, Math.max(0.0, initialConfidence)), + is_active_beacon: true, + allow_chain: false, // Beacons don't allow chaining + }; + } + + // 11. Resolve post expiration + let expiresAt: string | null = null; + if (ttlHours !== undefined) { + if (ttlHours > 0) { + expiresAt = new Date(Date.now() + ttlHours * 60 * 60 * 1000).toISOString(); + } else { + expiresAt = null; + } + } else { + const { data: settingsRow, error: settingsError } = await supabase + .from('user_settings') + .select('default_post_ttl') + .eq('user_id', user.id) + .maybeSingle(); + + if (settingsError) { + console.error('Error fetching user settings:', settingsError); + } else { + const defaultTtl = settingsRow?.default_post_ttl; + if (typeof defaultTtl === 'number' && defaultTtl > 0) { + expiresAt = new Date(Date.now() + defaultTtl * 60 * 60 * 1000).toISOString(); + } + } + } + + // 12. Create post with tags and beacon data + let postVisibility = 'public'; + const { data: privacyRow, error: privacyError } = await supabase + .from('profile_privacy_settings') + .select('posts_visibility') + .eq('user_id', user.id) + .maybeSingle(); + + if (privacyError) { + console.error('Error fetching privacy settings:', privacyError); + } else if (privacyRow?.posts_visibility) { + postVisibility = privacyRow.posts_visibility; + } + + const insertData: any = { + author_id: user.id, + category_id: postCategoryId ?? null, + body, + body_format: body_format ?? 'plain', + tone_label: toneLabel, + cis_score: cisScore, + status, + moderation_status: moderationStatus, + allow_chain: beaconData?.allow_chain ?? (allow_chain ?? true), + chain_parent_id: chain_parent_id ?? null, + image_url: image_url ?? null, + thumbnail_url: thumbnail_url ?? null, + tags: tags, + expires_at: expiresAt, + visibility: postVisibility, + }; + + // Add beacon fields if this is a beacon + if (beaconData) { + insertData.is_beacon = beaconData.is_beacon; + insertData.beacon_type = beaconData.beacon_type; + insertData.location = beaconData.location; + insertData.confidence_score = beaconData.confidence_score; + insertData.is_active_beacon = beaconData.is_active_beacon; + } + + // Use service client for INSERT to bypass RLS issues for private users + // The user authentication has already been validated above + const { data: post, error: postError } = await serviceClient + .from('posts') + .insert(insertData) + .select() + .single(); + + if (postError) { + console.error('Error creating post:', JSON.stringify({ + code: postError.code, + message: postError.message, + details: postError.details, + hint: postError.hint, + user_id: user.id, + })); + return new Response(JSON.stringify({ error: 'Failed to create post', details: postError.message }), { + status: 500, + headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, + }); + } + + // 13. Log successful post + await serviceClient.rpc('log_audit_event', { + p_actor_id: user.id, + p_event_type: 'post_created', + p_payload: { + post_id: post.id, + category_id: postCategoryId, + tone: toneLabel, + cis: cisScore, + chain_parent_id: chain_parent_id ?? null, + tags_count: tags.length, + is_beacon: is_beacon ?? false, + }, + }); + + // 14. Return post with metadata - FLATTENED STRUCTURE + let message = 'Your post is ready.'; + if (toneLabel === 'negative' || toneLabel === 'mixed') { + message = 'This post may have limited reach based on its tone.'; + } + + // Create a flattened post object that merges post data with location data + const flattenedPost: any = { + ...post, + // Add location fields at the top level for beacons + ...(beaconData && { + latitude: beacon_lat, + longitude: beacon_long, + }), + }; + + if (flattenedPost.image_url) { + flattenedPost.image_url = await trySignR2Url(flattenedPost.image_url); + } + if (flattenedPost.thumbnail_url) { + flattenedPost.thumbnail_url = await trySignR2Url(flattenedPost.thumbnail_url); + } + + const response: any = { + post: flattenedPost, + tone_analysis: { + tone: toneLabel, + cis: cisScore, + message, + }, + tags, + }; + + return new Response( + JSON.stringify(response), + { + status: 201, + headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, + } + ); + } catch (error) { + if (error instanceof ValidationError) { + return new Response( + JSON.stringify({ + error: 'Validation error', + message: error.message, + field: error.field, + }), + { + status: 400, + headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, + } + ); + } + + console.error('Unexpected error:', error); + return new Response(JSON.stringify({ error: 'Internal server error' }), { + status: 500, + headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, + }); + } +}); diff --git a/_legacy/supabase/functions/push-notification/config.toml b/_legacy/supabase/functions/push-notification/config.toml new file mode 100644 index 0000000..4d86786 --- /dev/null +++ b/_legacy/supabase/functions/push-notification/config.toml @@ -0,0 +1 @@ +verify_jwt = false diff --git a/_legacy/supabase/functions/push-notification/index.ts b/_legacy/supabase/functions/push-notification/index.ts new file mode 100644 index 0000000..dae2849 --- /dev/null +++ b/_legacy/supabase/functions/push-notification/index.ts @@ -0,0 +1,238 @@ +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.3'; +import { cert, getApps, initializeApp } from 'https://esm.sh/firebase-admin@11.10.1/app?target=deno&bundle'; +import { getMessaging } from 'https://esm.sh/firebase-admin@11.10.1/messaging?target=deno&bundle'; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +let supabase: ReturnType | null = null; + +function getSupabase() { + if (supabase) return supabase; + const url = Deno.env.get('SUPABASE_URL') ?? ''; + const key = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''; + if (!url || !key) { + throw new Error('Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY'); + } + supabase = createClient(url, key); + return supabase; +} + +function initFirebase() { + if (getApps().length > 0) return; + const raw = Deno.env.get('FIREBASE_SERVICE_ACCOUNT'); + if (!raw) { + throw new Error('Missing FIREBASE_SERVICE_ACCOUNT environment variable'); + } + const serviceAccount = JSON.parse(raw); + if (serviceAccount.private_key && typeof serviceAccount.private_key === 'string') { + serviceAccount.private_key = serviceAccount.private_key.replace(/\\n/g, '\n'); + } + initializeApp({ + credential: cert(serviceAccount), + }); +} + +// Clean up invalid FCM tokens from database +async function cleanupInvalidTokens( + supabaseClient: ReturnType, + tokens: string[], + responses: { success: boolean; error?: { code: string } }[] +) { + const invalidTokens: string[] = []; + + responses.forEach((response, index) => { + if (!response.success && response.error) { + const errorCode = response.error.code; + // These error codes indicate the token is no longer valid + if ( + errorCode === 'messaging/invalid-registration-token' || + errorCode === 'messaging/registration-token-not-registered' || + errorCode === 'messaging/invalid-argument' + ) { + invalidTokens.push(tokens[index]); + } + } + }); + + if (invalidTokens.length > 0) { + await supabaseClient + .from('user_fcm_tokens') + .delete() + .in('token', invalidTokens); + console.log(`Cleaned up ${invalidTokens.length} invalid FCM tokens`); + } + + return invalidTokens.length; +} + +Deno.serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeaders }); + } + + if (req.method === 'GET') { + const vapidKey = Deno.env.get('FIREBASE_WEB_VAPID_KEY') || ''; + return new Response(JSON.stringify({ firebase_web_vapid_key: vapidKey }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + try { + initFirebase(); + const supabaseClient = getSupabase(); + + const payload = await req.json(); + console.log('Received payload:', JSON.stringify(payload)); + + // Handle different payload formats: + // - Database webhook: { type: 'INSERT', table: '...', record: {...} } + // - Direct call: { conversation_id: '...', sender_id: '...' } + // - Alternative: { new: {...} } + const record = payload?.record ?? payload?.new ?? payload; + console.log('Extracted record:', JSON.stringify(record)); + + const conversationId = record?.conversation_id as string | undefined; + const senderId = record?.sender_id as string | undefined; + const messageType = record?.message_type != null + ? Number(record.message_type) + : undefined; + + console.log(`Processing: conversation=${conversationId}, sender=${senderId}, type=${messageType}`); + + if (!conversationId || !senderId) { + console.error('Missing required fields in payload'); + return new Response(JSON.stringify({ error: 'Missing conversation_id or sender_id', receivedPayload: payload }), { + status: 400, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + if (messageType === 2) { + return new Response(JSON.stringify({ skipped: true, reason: 'command_message' }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + const { data: conversation, error: conversationError } = await supabaseClient + .from('encrypted_conversations') + .select('participant_a, participant_b') + .eq('id', conversationId) + .single(); + + if (conversationError || !conversation) { + return new Response(JSON.stringify({ error: 'Conversation not found' }), { + status: 404, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + const receiverId = + conversation.participant_a === senderId + ? conversation.participant_b + : conversation.participant_a; + + if (!receiverId) { + return new Response(JSON.stringify({ error: 'Receiver not resolved' }), { + status: 400, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + const { data: senderProfile } = await supabaseClient + .from('profiles') + .select('handle, display_name') + .eq('id', senderId) + .single(); + + const senderName = + senderProfile?.display_name?.trim() || + (senderProfile?.handle ? `@${senderProfile.handle}` : 'Someone'); + + const { data: tokens } = await supabaseClient + .from('user_fcm_tokens') + .select('token') + .eq('user_id', receiverId); + + const tokenList = (tokens ?? []) + .map((row) => row.token as string) + .filter((token) => !!token); + + if (tokenList.length == 0) { + console.log(`No FCM tokens found for receiver ${receiverId}`); + return new Response(JSON.stringify({ skipped: true, reason: 'no_tokens', receiverId }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + console.log(`Sending to ${tokenList.length} token(s) for receiver ${receiverId}`); + + const messaging = getMessaging(); + const response = await messaging.sendEachForMulticast({ + tokens: tokenList, + notification: { + title: `New Message from ${senderName}`, + body: '🔒 [Encrypted Message]', + }, + data: { + conversation_id: conversationId, + type: 'chat', + }, + // Android-specific options + android: { + priority: 'high', + notification: { + channelId: 'chat_messages', + priority: 'high', + }, + }, + // iOS-specific options + apns: { + payload: { + aps: { + sound: 'default', + badge: 1, + contentAvailable: true, + }, + }, + }, + }); + + // Clean up any invalid tokens + const cleanedUp = await cleanupInvalidTokens( + supabaseClient, + tokenList, + response.responses + ); + + console.log(`Push notification sent: ${response.successCount} success, ${response.failureCount} failed, ${cleanedUp} tokens cleaned up`); + + // Log individual failures for debugging + response.responses.forEach((resp, index) => { + if (!resp.success && resp.error) { + console.error(`Failed to send to token ${index}: ${resp.error.code} - ${resp.error.message}`); + } + }); + + return new Response( + JSON.stringify({ + success: true, + sent: response.successCount, + failed: response.failureCount, + tokensCleanedUp: cleanedUp, + }), + { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + } + ); + } catch (error) { + console.error('Push notification error:', error); + return new Response(JSON.stringify({ error: error.message ?? 'Unknown error' }), { + status: 500, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } +}); diff --git a/_legacy/supabase/functions/report/config.toml b/_legacy/supabase/functions/report/config.toml new file mode 100644 index 0000000..4d86786 --- /dev/null +++ b/_legacy/supabase/functions/report/config.toml @@ -0,0 +1 @@ +verify_jwt = false diff --git a/_legacy/supabase/functions/report/index.ts b/_legacy/supabase/functions/report/index.ts new file mode 100644 index 0000000..3b81f2f --- /dev/null +++ b/_legacy/supabase/functions/report/index.ts @@ -0,0 +1,250 @@ +/** + * POST /report + * + * Design intent: + * - Strict reasons only. + * - Reports never auto-remove content. + * - Reporting accuracy affects reporter trust. + * + * Flow: + * 1. Validate auth and inputs + * 2. Ensure target exists and is visible + * 3. Create report record + * 4. Log audit event + * 5. Queue for moderation review + */ + +import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'; +import { createSupabaseClient, createServiceClient } from '../_shared/supabase-client.ts'; +import { validateReportReason, validateUUID, ValidationError } from '../_shared/validation.ts'; + +type TargetType = 'post' | 'comment' | 'profile'; + +interface ReportRequest { + target_type: TargetType; + target_id: string; + reason: string; +} + +serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', + }, + }); + } + + try { + // 1. Validate auth + const authHeader = req.headers.get('Authorization'); + if (!authHeader) { + return new Response(JSON.stringify({ error: 'Missing authorization header' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const supabase = createSupabaseClient(authHeader); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError || !user) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // 2. Parse request + const { target_type, target_id, reason } = (await req.json()) as ReportRequest; + + // 3. Validate inputs + if (!['post', 'comment', 'profile'].includes(target_type)) { + throw new ValidationError('Invalid target type', 'target_type'); + } + + validateUUID(target_id, 'target_id'); + validateReportReason(reason); + + // 4. Verify target exists and is visible to reporter + let targetExists = false; + let targetAuthorId: string | null = null; + + if (target_type === 'post') { + const { data: post } = await supabase + .from('posts') + .select('author_id') + .eq('id', target_id) + .single(); + + if (post) { + targetExists = true; + targetAuthorId = post.author_id; + } + } else if (target_type === 'comment') { + const { data: comment } = await supabase + .from('comments') + .select('author_id') + .eq('id', target_id) + .single(); + + if (comment) { + targetExists = true; + targetAuthorId = comment.author_id; + } + } else if (target_type === 'profile') { + const { data: profile } = await supabase + .from('profiles') + .select('id') + .eq('id', target_id) + .single(); + + if (profile) { + targetExists = true; + targetAuthorId = target_id; + } + } + + if (!targetExists) { + return new Response( + JSON.stringify({ + error: 'Target not found', + message: 'The content you are trying to report does not exist or you cannot see it.', + }), + { + status: 404, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + // 5. Prevent self-reporting + if (targetAuthorId === user.id) { + return new Response( + JSON.stringify({ + error: 'Invalid report', + message: 'You cannot report your own content.', + }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + // 6. Check for duplicate reports (constraint will prevent, but give better message) + const { data: existingReport } = await supabase + .from('reports') + .select('id') + .eq('reporter_id', user.id) + .eq('target_type', target_type) + .eq('target_id', target_id) + .single(); + + if (existingReport) { + return new Response( + JSON.stringify({ + error: 'Duplicate report', + message: 'You have already reported this.', + }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + // 7. Create report + const { data: report, error: reportError } = await supabase + .from('reports') + .insert({ + reporter_id: user.id, + target_type, + target_id, + reason, + status: 'pending', + }) + .select() + .single(); + + if (reportError) { + console.error('Error creating report:', reportError); + return new Response(JSON.stringify({ error: 'Failed to create report' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // 8. Update reporter's trust counters + const serviceClient = createServiceClient(); + await serviceClient.rpc('log_audit_event', { + p_actor_id: user.id, + p_event_type: 'report_filed', + p_payload: { + report_id: report.id, + target_type, + target_id, + target_author_id: targetAuthorId, + }, + }); + + // Increment reports_filed counter + const { error: counterError } = await serviceClient + .from('trust_state') + .update({ + counters: supabase.rpc('jsonb_set', { + target: supabase.rpc('counters'), + path: '{reports_filed}', + new_value: supabase.rpc('to_jsonb', [ + supabase.rpc('jsonb_extract_path_text', ['counters', 'reports_filed']) + 1, + ]), + }), + updated_at: new Date().toISOString(), + }) + .eq('user_id', user.id); + + if (counterError) { + console.warn('Failed to update report counter:', counterError); + // Continue anyway - report was created + } + + // 9. Return success + return new Response( + JSON.stringify({ + success: true, + report_id: report.id, + message: + 'Report received. All reports are reviewed. False reports may affect your account standing.', + }), + { + status: 201, + headers: { 'Content-Type': 'application/json' }, + } + ); + } catch (error) { + if (error instanceof ValidationError) { + return new Response( + JSON.stringify({ + error: 'Validation error', + message: error.message, + field: error.field, + }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + console.error('Unexpected error:', error); + return new Response(JSON.stringify({ error: 'Internal server error' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +}); diff --git a/_legacy/supabase/functions/save/config.toml b/_legacy/supabase/functions/save/config.toml new file mode 100644 index 0000000..4d86786 --- /dev/null +++ b/_legacy/supabase/functions/save/config.toml @@ -0,0 +1 @@ +verify_jwt = false diff --git a/_legacy/supabase/functions/save/index.ts b/_legacy/supabase/functions/save/index.ts new file mode 100644 index 0000000..cd95fae --- /dev/null +++ b/_legacy/supabase/functions/save/index.ts @@ -0,0 +1,177 @@ +/** + * POST /save - Save a post (private bookmark) + * DELETE /save - Remove from saved + * + * Design intent: + * - Saves are private, for personal curation + * - More intentional than appreciation + * - Saves > likes in ranking algorithm + */ + +import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'; +import { createSupabaseClient, createServiceClient } from '../_shared/supabase-client.ts'; +import { validateUUID, ValidationError } from '../_shared/validation.ts'; + +interface SaveRequest { + post_id: string; +} + +serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, DELETE', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', + }, + }); + } + + try { + const authHeader = req.headers.get('Authorization'); + if (!authHeader) { + return new Response(JSON.stringify({ error: 'Missing authorization header' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const supabase = createSupabaseClient(authHeader); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError || !user) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const { post_id } = (await req.json()) as SaveRequest; + validateUUID(post_id, 'post_id'); + + // Use admin client to bypass RLS issues + const adminClient = createServiceClient(); + + // Verify post exists and check visibility + const { data: postRow, error: postError } = await adminClient + .from('posts') + .select('id, visibility, author_id, status') + .eq('id', post_id) + .maybeSingle(); + + if (postError || !postRow) { + console.error('Post lookup failed:', { post_id, error: postError?.message }); + return new Response( + JSON.stringify({ error: 'Post not found' }), + { status: 404, headers: { 'Content-Type': 'application/json' } } + ); + } + + // Check if post is active + if (postRow.status !== 'active') { + return new Response( + JSON.stringify({ error: 'Post is not available' }), + { status: 404, headers: { 'Content-Type': 'application/json' } } + ); + } + + // For private posts, verify the user has access (must be author) + if (postRow.visibility === 'private' && postRow.author_id !== user.id) { + return new Response( + JSON.stringify({ error: 'Post not accessible' }), + { status: 403, headers: { 'Content-Type': 'application/json' } } + ); + } + + // For followers-only posts, verify the user follows the author + if (postRow.visibility === 'followers' && postRow.author_id !== user.id) { + const { data: followRow } = await adminClient + .from('follows') + .select('status') + .eq('follower_id', user.id) + .eq('following_id', postRow.author_id) + .eq('status', 'accepted') + .maybeSingle(); + + if (!followRow) { + return new Response( + JSON.stringify({ error: 'You must follow this user to save their posts' }), + { status: 403, headers: { 'Content-Type': 'application/json' } } + ); + } + } + + // Handle unsave (DELETE) + if (req.method === 'DELETE') { + const { error: deleteError } = await adminClient + .from('post_saves') + .delete() + .eq('user_id', user.id) + .eq('post_id', post_id); + + if (deleteError) { + console.error('Error removing save:', deleteError); + return new Response(JSON.stringify({ error: 'Failed to remove from saved' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } + + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Handle save (POST) + const { error: saveError } = await adminClient + .from('post_saves') + .insert({ + user_id: user.id, + post_id, + }); + + if (saveError) { + // Already saved (duplicate key) + if (saveError.code === '23505') { + return new Response( + JSON.stringify({ error: 'Post already saved' }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + + console.error('Error saving post:', saveError); + return new Response(JSON.stringify({ error: 'Failed to save post' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } + + return new Response( + JSON.stringify({ + success: true, + message: 'Saved. You can find this in your collection.', + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + } catch (error) { + if (error instanceof ValidationError) { + return new Response( + JSON.stringify({ error: 'Validation error', message: error.message }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + + console.error('Unexpected error:', error); + return new Response(JSON.stringify({ error: 'Internal server error' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +}); diff --git a/_legacy/supabase/functions/search/config.toml b/_legacy/supabase/functions/search/config.toml new file mode 100644 index 0000000..4d86786 --- /dev/null +++ b/_legacy/supabase/functions/search/config.toml @@ -0,0 +1 @@ +verify_jwt = false diff --git a/_legacy/supabase/functions/search/index.ts b/_legacy/supabase/functions/search/index.ts new file mode 100644 index 0000000..c25421e --- /dev/null +++ b/_legacy/supabase/functions/search/index.ts @@ -0,0 +1,287 @@ +import { serve } from "https://deno.land/std@0.177.0/http/server.ts"; +import { createSupabaseClient, createServiceClient } from "../_shared/supabase-client.ts"; +import { trySignR2Url } from "../_shared/r2_signer.ts"; + +interface Profile { + id: string; + handle: string; + display_name: string; + avatar_url: string | null; + harmony_tier: string | null; +} + +interface SearchUser { + id: string; + username: string; + display_name: string; + avatar_url: string | null; + harmony_tier: string; +} + +interface SearchTag { + tag: string; + count: number; +} + +interface SearchPost { + id: string; + body: string; + author_id: string; + author_handle: string; + author_display_name: string; + created_at: string; + image_url: string | null; + visibility?: string; +} + +function extractHashtags(text: string): string[] { + const matches = text.match(/#\w+/g) || []; + return [...new Set(matches.map((t) => t.replace("#", "").toLowerCase()))].filter((t) => t.length > 0); +} + +function stripHashtags(text: string): string { + return text.replace(/#\w+/g, " ").replace(/\s+/g, " ").trim(); +} + +serve(async (req: Request) => { + // 1. Handle CORS Preflight + if (req.method === "OPTIONS") { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", + }, + }); + } + + try { + // 2. Auth & Input Parsing + const authHeader = req.headers.get("Authorization"); + if (!authHeader) throw new Error("Missing authorization header"); + + let query: string | null = null; + if (req.method === "POST") { + try { + const body = await req.json(); + query = body.query; + } catch { /* ignore parsing error */ } + } + if (!query) { + const url = new URL(req.url); + query = url.searchParams.get("query"); + } + + // Return empty if no query + if (!query || query.trim().length === 0) { + return new Response( + JSON.stringify({ users: [], tags: [], posts: [] }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + const rawQuery = query.trim(); + const isHashtagSearch = rawQuery.startsWith("#"); + const cleanTag = isHashtagSearch ? rawQuery.replace("#", "").toLowerCase() : ""; + const hashtagFilters = extractHashtags(rawQuery); + const ftsQuery = stripHashtags(rawQuery).replace(/[|&!]/g, " ").trim(); + const hasFts = ftsQuery.length > 0; + const safeQuery = rawQuery.toLowerCase().replace(/[,()]/g, ""); + + console.log("Search query:", rawQuery, "isHashtagSearch:", isHashtagSearch, "cleanTag:", cleanTag); + + const supabase = createSupabaseClient(authHeader); + const serviceClient = createServiceClient(); + + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) throw new Error("Unauthorized"); + + // 3. Prepare Exclusion Lists (Blocked Users) + const { data: blockedUsers } = await serviceClient + .from("blocks") + .select("blocked_id") + .eq("blocker_id", user.id); + + const blockedIds = (blockedUsers?.map((b) => b.blocked_id) || []); + const excludeIds = [...blockedIds, user.id]; // Exclude self from user search + const postExcludeIds = blockedIds; // Allow own posts in search + + // 4. Parallel Execution (Fastest Approach) + // Users + tags first so tag matches can inform post search. + const [usersResult, tagsResult] = await Promise.all([ + + // A. Search Users + (async () => { + let dbQuery = serviceClient + .from("profiles") + .select("id, handle, display_name, avatar_url") + .or(`handle.ilike.%${safeQuery}%,display_name.ilike.%${safeQuery}%`) + .limit(5); + + if (excludeIds.length > 0) { + dbQuery = dbQuery.not("id", "in", `(${excludeIds.join(",")})`); + } + return await dbQuery; + })(), + + // B. Search Tags (Using the View for performance) + (async () => { + // NOTE: Ensure you have run the SQL to create 'view_searchable_tags' + // If view missing, this returns error, handle gracefully + return await serviceClient + .from("view_searchable_tags") + .select("tag, count") + .ilike("tag", `%${isHashtagSearch ? cleanTag : safeQuery}%`) + .order("count", { ascending: false }) + .limit(5); + })(), + + ]); + + const matchedTags = (tagsResult.data || []) + .map((t: any) => String(t.tag).toLowerCase()) + .filter((t: string) => t.length > 0); + + const tagCandidates = matchedTags.length > 0 + ? matchedTags + : (isHashtagSearch && cleanTag.length > 0 ? [cleanTag] : []); + + // C. Search Posts (tag-first for hashtag queries; hybrid for others) + const postsResult = await (async () => { + const postsMap = new Map(); + + if (isHashtagSearch) { + if (cleanTag.length > 0) { + let exactTagQuery = serviceClient + .from("posts") + .select("id, body, tags, created_at, author_id, image_url, visibility, profiles!posts_author_id_fkey(handle, display_name)") + .contains("tags", [cleanTag]) + .order("created_at", { ascending: false }) + .limit(20); + if (excludeIds.length > 0) { + exactTagQuery = exactTagQuery.not("author_id", "in", `(${excludeIds.join(",")})`); + } + const exactTagResult = await exactTagQuery; + (exactTagResult.data || []).forEach((p: any) => postsMap.set(p.id, p)); + } + + if (tagCandidates.length > 0) { + let tagQuery = serviceClient + .from("posts") + .select("id, body, tags, created_at, author_id, image_url, visibility, profiles!posts_author_id_fkey(handle, display_name)") + .overlaps("tags", tagCandidates) + .order("created_at", { ascending: false }) + .limit(20); + if (postExcludeIds.length > 0) { + tagQuery = tagQuery.not("author_id", "in", `(${postExcludeIds.join(",")})`); + } + const tagResult = await tagQuery; + (tagResult.data || []).forEach((p: any) => postsMap.set(p.id, p)); + } + + return { data: Array.from(postsMap.values()).slice(0, 20), error: null }; + } + + if (hasFts) { + let ftsDbQuery = serviceClient + .from("posts") + .select("id, body, tags, created_at, author_id, image_url, visibility, profiles!posts_author_id_fkey(handle, display_name)") + .order("created_at", { ascending: false }) + .limit(20); + if (postExcludeIds.length > 0) { + ftsDbQuery = ftsDbQuery.not("author_id", "in", `(${postExcludeIds.join(",")})`); + } + ftsDbQuery = ftsDbQuery.textSearch("fts", ftsQuery, { + type: "websearch", + config: "english", + }); + const ftsResult = await ftsDbQuery; + (ftsResult.data || []).forEach((p: any) => postsMap.set(p.id, p)); + } + + if (tagCandidates.length > 0) { + let tagOverlapQuery = serviceClient + .from("posts") + .select("id, body, tags, created_at, author_id, image_url, visibility, profiles!posts_author_id_fkey(handle, display_name)") + .overlaps("tags", tagCandidates) + .order("created_at", { ascending: false }) + .limit(20); + if (postExcludeIds.length > 0) { + tagOverlapQuery = tagOverlapQuery.not("author_id", "in", `(${postExcludeIds.join(",")})`); + } + const tagOverlapResult = await tagOverlapQuery; + (tagOverlapResult.data || []).forEach((p: any) => postsMap.set(p.id, p)); + } + + if (hashtagFilters.length > 0) { + let tagOverlapQuery = serviceClient + .from("posts") + .select("id, body, tags, created_at, author_id, image_url, visibility, profiles!posts_author_id_fkey(handle, display_name)") + .overlaps("tags", hashtagFilters) + .order("created_at", { ascending: false }) + .limit(20); + if (postExcludeIds.length > 0) { + tagOverlapQuery = tagOverlapQuery.not("author_id", "in", `(${postExcludeIds.join(",")})`); + } + const tagOverlapResult = await tagOverlapQuery; + (tagOverlapResult.data || []).forEach((p: any) => postsMap.set(p.id, p)); + } + + return { data: Array.from(postsMap.values()).slice(0, 20), error: null }; + })(); + + // 5. Process Users (Get Harmony Tiers) + const profiles = usersResult.data || []; + let users: SearchUser[] = []; + + if (profiles.length > 0) { + const { data: trustStates } = await serviceClient + .from("trust_state") + .select("user_id, tier") + .in("user_id", profiles.map(p => p.id)); + + const trustMap = new Map(trustStates?.map(t => [t.user_id, t.tier]) || []); + + users = profiles.map((p: any) => ({ + id: p.id, + username: p.handle, + display_name: p.display_name || p.handle, + avatar_url: p.avatar_url, + harmony_tier: trustMap.get(p.id) || "new", + })); + } + + // 6. Process Tags + const tags: SearchTag[] = (tagsResult.data || []).map((t: any) => ({ + tag: t.tag, + count: t.count + })); + + // 7. Process Posts + const searchPosts: SearchPost[] = await Promise.all( + (postsResult.data || []).map(async (p: any) => ({ + id: p.id, + body: p.body, + author_id: p.author_id, + author_handle: p.profiles?.handle || "unknown", + author_display_name: p.profiles?.display_name || "Unknown User", + created_at: p.created_at, + image_url: p.image_url ? await trySignR2Url(p.image_url) : null, + visibility: p.visibility, + })) + ); + + // 8. Return Result + return new Response(JSON.stringify({ users, tags, posts: searchPosts }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + + } catch (error: any) { + console.error("Search error:", error); + return new Response( + JSON.stringify({ error: error.message || "Internal server error" }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } +}); diff --git a/_legacy/supabase/functions/sign-media/config.toml b/_legacy/supabase/functions/sign-media/config.toml new file mode 100644 index 0000000..4d86786 --- /dev/null +++ b/_legacy/supabase/functions/sign-media/config.toml @@ -0,0 +1 @@ +verify_jwt = false diff --git a/_legacy/supabase/functions/sign-media/index.ts b/_legacy/supabase/functions/sign-media/index.ts new file mode 100644 index 0000000..a26303b --- /dev/null +++ b/_legacy/supabase/functions/sign-media/index.ts @@ -0,0 +1,80 @@ +import { serve } from "https://deno.land/std@0.177.0/http/server.ts"; +import { createSupabaseClient } from "../_shared/supabase-client.ts"; +import { trySignR2Url, transformLegacyMediaUrl } from "../_shared/r2_signer.ts"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", +}; + +serve(async (req) => { + if (req.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + if (req.method !== "POST") { + return new Response(JSON.stringify({ error: "Method not allowed" }), { + status: 405, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + try { + const authHeader = req.headers.get("Authorization"); + if (!authHeader) { + return new Response(JSON.stringify({ error: "Missing authorization header" }), { + status: 401, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + const supabase = createSupabaseClient(authHeader); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError || !user) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + const body = await req.json().catch(() => ({})); + const url = body?.url as string | undefined; + const key = body?.key as string | undefined; + const expiresIn = Number.isFinite(body?.expiresIn) ? Number(body.expiresIn) : 3600; + + const target = key || url; + if (!target) { + return new Response(JSON.stringify({ error: "Missing url or key" }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + // Transform legacy media.sojorn.net URLs to their object key + const transformedTarget = transformLegacyMediaUrl(target) ?? target; + + const signedUrl = await trySignR2Url(transformedTarget, expiresIn); + if (!signedUrl) { + return new Response(JSON.stringify({ error: "Failed to sign media URL" }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + return new Response(JSON.stringify({ signedUrl, signed_url: signedUrl }), { + status: 200, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Internal server error"; + return new Response(JSON.stringify({ error: message }), { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } +}); diff --git a/_legacy/supabase/functions/signup/config.toml b/_legacy/supabase/functions/signup/config.toml new file mode 100644 index 0000000..4d86786 --- /dev/null +++ b/_legacy/supabase/functions/signup/config.toml @@ -0,0 +1 @@ +verify_jwt = false diff --git a/_legacy/supabase/functions/signup/index.ts b/_legacy/supabase/functions/signup/index.ts new file mode 100644 index 0000000..8734474 --- /dev/null +++ b/_legacy/supabase/functions/signup/index.ts @@ -0,0 +1,195 @@ +/** + * POST /signup + * + * User registration and profile creation + * Creates profile + initializes trust_state + * + * Flow: + * 1. User signs up via Supabase Auth (handled by client) + * 2. Client calls this function with profile details + * 3. Create profile record + * 4. Trust state is auto-initialized by trigger + * 5. Return profile data + */ + +import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'; +import { createSupabaseClient } from '../_shared/supabase-client.ts'; +import { ValidationError } from '../_shared/validation.ts'; + +interface SignupRequest { + handle: string; + display_name: string; + bio?: string; +} + +serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { + headers: { + 'Access-Control-Allow-Origin': Deno.env.get('ALLOWED_ORIGIN') || 'https://sojorn.net', + 'Access-Control-Allow-Methods': 'POST', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', + }, + }); + } + + try { + // 1. Validate auth + const authHeader = req.headers.get('Authorization'); + if (!authHeader) { + return new Response(JSON.stringify({ error: 'Missing authorization header' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const supabase = createSupabaseClient(authHeader); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError || !user) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // 2. Parse request + const { handle, display_name, bio } = (await req.json()) as SignupRequest; + + // 3. Validate inputs + if (!handle || !handle.match(/^[a-z0-9_]{3,20}$/)) { + throw new ValidationError( + 'Handle must be 3-20 characters, lowercase letters, numbers, and underscores only', + 'handle' + ); + } + + if (!display_name || display_name.trim().length === 0 || display_name.length > 50) { + throw new ValidationError('Display name must be 1-50 characters', 'display_name'); + } + + if (bio && bio.length > 300) { + throw new ValidationError('Bio must be 300 characters or less', 'bio'); + } + + // 3b. Get origin country from IP geolocation + // Uses ipinfo.io to look up country from client IP address + let originCountry: string | null = null; + const clientIp = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim(); + const ipinfoToken = Deno.env.get('IPINFO_TOKEN'); + + if (clientIp && ipinfoToken) { + try { + const geoRes = await fetch(`https://api.ipinfo.io/lite/${clientIp}?token=${ipinfoToken}`); + if (geoRes.ok) { + const geoData = await geoRes.json(); + // ipinfo.io returns country as ISO 3166-1 alpha-2 code (e.g., 'US', 'GB') + if (geoData.country && /^[A-Z]{2}$/.test(geoData.country)) { + originCountry = geoData.country; + } + } + } catch (geoError) { + // Geolocation is optional - don't fail signup if it errors + console.warn('Geolocation lookup failed:', geoError); + } + } + + // 4. Check if profile already exists + const { data: existingProfile } = await supabase + .from('profiles') + .select('id') + .eq('id', user.id) + .single(); + + if (existingProfile) { + return new Response( + JSON.stringify({ + error: 'Profile already exists', + message: 'You have already completed signup', + }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + // 5. Create profile (trust_state will be auto-created by trigger) + const { data: profile, error: profileError } = await supabase + .from('profiles') + .insert({ + id: user.id, + handle, + display_name, + bio: bio || null, + origin_country: originCountry, + }) + .select() + .single(); + + if (profileError) { + // Check for duplicate handle + if (profileError.code === '23505') { + return new Response( + JSON.stringify({ + error: 'Handle taken', + message: 'This handle is already in use. Please choose another.', + }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + console.error('Error creating profile:', profileError); + return new Response(JSON.stringify({ error: 'Failed to create profile' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // 6. Get the auto-created trust state + const { data: trustState } = await supabase + .from('trust_state') + .select('harmony_score, tier') + .eq('user_id', user.id) + .single(); + + // 7. Return profile data + return new Response( + JSON.stringify({ + profile, + trust_state: trustState, + message: 'Welcome to sojorn. Your journey begins quietly.', + }), + { + status: 201, + headers: { 'Content-Type': 'application/json' }, + } + ); + } catch (error) { + if (error instanceof ValidationError) { + return new Response( + JSON.stringify({ + error: 'Validation error', + message: error.message, + field: error.field, + }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + console.error('Unexpected error:', error); + return new Response(JSON.stringify({ error: 'Internal server error' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +}); diff --git a/_legacy/supabase/functions/tone-check/config.toml b/_legacy/supabase/functions/tone-check/config.toml new file mode 100644 index 0000000..4d86786 --- /dev/null +++ b/_legacy/supabase/functions/tone-check/config.toml @@ -0,0 +1 @@ +verify_jwt = false diff --git a/_legacy/supabase/functions/tone-check/index.ts b/_legacy/supabase/functions/tone-check/index.ts new file mode 100644 index 0000000..b916f32 --- /dev/null +++ b/_legacy/supabase/functions/tone-check/index.ts @@ -0,0 +1,202 @@ +/// +import { serve } from 'https://deno.land/std@0.168.0/http/server.ts' + +const OPENAI_MODERATION_URL = 'https://api.openai.com/v1/moderations' + +const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || 'https://sojorn.net'; + +const corsHeaders = { + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +} + +type ModerationCategory = 'bigotry' | 'nsfw' | 'violence' + +interface ModerationResult { + flagged: boolean + category?: ModerationCategory + flags: string[] + reason: string +} + +// Basic keyword-based fallback (when OpenAI is unavailable) +function basicModeration(text: string): ModerationResult { + const lowerText = text.toLowerCase() + const flags: string[] = [] + + // Slurs and hate speech patterns (basic detection) + const slurPatterns = [ + /\bn+[i1]+g+[aegr]+/i, + /\bf+[a4]+g+[s$o0]+t/i, + /\br+[e3]+t+[a4]+r+d/i, + // Add more patterns as needed + ] + + for (const pattern of slurPatterns) { + if (pattern.test(text)) { + return { + flagged: true, + category: 'bigotry', + flags: ['hate-speech'], + reason: 'This content contains hate speech or slurs.', + } + } + } + + // Targeted profanity/attacks + const attackPatterns = [ + /\b(fuck|screw|damn)\s+(you|u|your|ur)\b/i, + /\byou('re| are)\s+(a |an )?(fucking |damn |stupid |idiot|moron|dumb)/i, + /\b(kill|hurt|attack|destroy)\s+(you|yourself)\b/i, + /\byou\s+should\s+(die|kill|hurt)/i, + ] + + for (const pattern of attackPatterns) { + if (pattern.test(text)) { + flags.push('harassment') + return { + flagged: true, + category: 'bigotry', + flags, + reason: 'This content appears to be harassing or attacking someone.', + } + } + } + + // Positive indicators + const positiveWords = ['thank', 'appreciate', 'love', 'support', 'grateful', 'amazing', 'wonderful'] + const hasPositive = positiveWords.some(word => lowerText.includes(word)) + + if (hasPositive) { + return { + flagged: false, + flags: [], + reason: 'Content approved', + } + } + + // Default: Allow + return { + flagged: false, + flags: [], + reason: 'Content approved', + } +} + +serve(async (req: Request) => { + // Handle CORS + if (req.method === 'OPTIONS') { + return new Response('ok', { + headers: { + ...corsHeaders, + 'Access-Control-Allow-Methods': 'POST OPTIONS', + }, + }) + } + + try { + const { text, imageUrl } = await req.json() as { text: string; imageUrl?: string } + + if (!text || text.trim().length === 0) { + return new Response( + JSON.stringify({ error: 'Text is required' }), + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ) + } + + const openAiKey = Deno.env.get('OPEN_AI') + + // Try OpenAI Moderation API first (if key available) + if (openAiKey) { + try { + console.log('Attempting OpenAI moderation check...') + const input: Array<{ type: 'text'; text: string } | { type: 'image_url'; image_url: { url: string } }> = [ + { type: 'text', text }, + ] + + if (imageUrl) { + input.push({ + type: 'image_url', + image_url: { url: imageUrl }, + }) + } + + const moderationResponse = await fetch(OPENAI_MODERATION_URL, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${openAiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + input, + model: 'omni-moderation-latest', + }), + }) + + if (moderationResponse.ok) { + const moderationData = await moderationResponse.json() + const results = moderationData.results?.[0] + + if (results) { + const categories = results.categories || {} + const flags = Object.entries(categories) + .filter(([, value]) => value === true) + .map(([key]) => key) + + const isHate = categories.hate || categories['hate/threatening'] + const isHarassment = categories.harassment || categories['harassment/threatening'] + const isSexual = categories.sexual || categories['sexual/minors'] + const isViolence = categories.violence || categories['violence/graphic'] + + let category: ModerationCategory | undefined + let reason = 'Content approved' + const flagged = Boolean(isHate || isHarassment || isSexual || isViolence) + + if (flagged) { + if (isHate || isHarassment) { + category = 'bigotry' + reason = 'Potential hate or harassment detected.' + } else if (isSexual) { + category = 'nsfw' + reason = 'Potential sexual content detected.' + } else if (isViolence) { + category = 'violence' + reason = 'Potential violent content detected.' + } + } + + console.log('OpenAI moderation successful:', { flagged, category }) + return new Response(JSON.stringify({ flagged, category, flags, reason }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }) + } + } else { + const errorText = await moderationResponse.text() + console.error('OpenAI API error:', moderationResponse.status, errorText) + } + } catch (error) { + console.error('OpenAI moderation failed:', error) + } + } + + // Fallback to basic keyword moderation + console.log('Using basic keyword moderation (OpenAI unavailable)') + const result = basicModeration(text) + + return new Response(JSON.stringify(result), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }) + } catch (e) { + console.error('Error in tone-check function:', e) + // Fail CLOSED: Reject content when moderation fails + return new Response( + JSON.stringify({ + flagged: true, + category: null, + flags: [], + reason: 'Content moderation is temporarily unavailable. Please try again later.', + }), + { status: 503, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ) + } +}) diff --git a/_legacy/supabase/functions/trending/config.toml b/_legacy/supabase/functions/trending/config.toml new file mode 100644 index 0000000..4d86786 --- /dev/null +++ b/_legacy/supabase/functions/trending/config.toml @@ -0,0 +1 @@ +verify_jwt = false diff --git a/_legacy/supabase/functions/trending/index.ts b/_legacy/supabase/functions/trending/index.ts new file mode 100644 index 0000000..9da892b --- /dev/null +++ b/_legacy/supabase/functions/trending/index.ts @@ -0,0 +1,345 @@ +/** + * GET /trending + * + * Design intent: + * - Trending reflects calm resonance, not excitement. + * - Nothing trends forever. + * - Categories do not compete. + * + * Implementation: + * - Category-scoped lists only (no global trending) + * - Eligibility: Positive or Neutral tone, High CIS, Low block/report rate + * - Rank by calm velocity (steady appreciation > spikes) + * - Allow admin editorial override with expiration + */ + +import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'; +import { createSupabaseClient, createServiceClient } from '../_shared/supabase-client.ts'; +import { rankPosts, type PostForRanking } from '../_shared/ranking.ts'; + +serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', + }, + }); + } + + try { + // 1. Validate auth + const authHeader = req.headers.get('Authorization'); + if (!authHeader) { + return new Response(JSON.stringify({ error: 'Missing authorization header' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const supabase = createSupabaseClient(authHeader); + const serviceClient = createServiceClient(); + + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError || !user) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // 2. Parse query params + const url = new URL(req.url); + const categorySlug = url.searchParams.get('category'); + const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 50); + + if (!categorySlug) { + return new Response( + JSON.stringify({ + error: 'Missing category', + message: 'Trending is category-scoped. Provide a category slug.', + }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + // 3. Get category ID + const { data: category, error: categoryError } = await supabase + .from('categories') + .select('id, name, slug') + .eq('slug', categorySlug) + .single(); + + if (categoryError || !category) { + return new Response(JSON.stringify({ error: 'Category not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // 4. Check for editorial overrides (unexpired) + const { data: overrides } = await supabase + .from('trending_overrides') + .select( + ` + post_id, + reason, + posts ( + id, + body, + created_at, + tone_label, + cis_score, + allow_chain, + chain_parent_id, + chain_parent:posts!posts_chain_parent_id_fkey ( + id, + body, + created_at, + author:profiles!posts_author_id_fkey ( + id, + handle, + display_name, + avatar_url + ) + ), + author:profiles!posts_author_id_fkey ( + id, + handle, + display_name, + avatar_url + ), + category:categories!posts_category_id_fkey ( + id, + slug, + name + ), + metrics:post_metrics ( + like_count, + save_count + ) + ) + ` + ) + .eq('category_id', category.id) + .gt('expires_at', new Date().toISOString()) + .order('created_at', { ascending: false }); + + const overridePosts = + overrides?.map((o: any) => ({ + ...o.posts, + is_editorial: true, + editorial_reason: o.reason, + })) || []; + + // 5. Fetch candidate posts for algorithmic trending + // Eligibility: + // - Positive or Neutral tone only + // - CIS >= 0.8 (high content integrity) + // - Created in last 48 hours (trending is recent) + // - Active status + const twoDaysAgo = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString(); + + const { data: posts, error: postsError } = await serviceClient + .from('posts') + .select( + ` + id, + body, + created_at, + category_id, + tone_label, + cis_score, + author_id, + author:profiles!posts_author_id_fkey ( + id, + handle, + display_name, + avatar_url + ), + category:categories!posts_category_id_fkey ( + id, + slug, + name + ), + metrics:post_metrics ( + like_count, + save_count, + view_count + ) + ` + ) + .eq('category_id', category.id) + .in('tone_label', ['positive', 'neutral']) + .gte('cis_score', 0.8) + .gte('created_at', twoDaysAgo) + .eq('status', 'active') + .limit(100); // Candidate pool + + if (postsError) { + console.error('Error fetching trending posts:', postsError); + return new Response(JSON.stringify({ error: 'Failed to fetch trending posts' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // 6. Enrich posts with safety metrics + const authorIds = [...new Set(posts.map((p) => p.author_id))]; + + const { data: trustStates } = await serviceClient + .from('trust_state') + .select('user_id, harmony_score, tier') + .in('user_id', authorIds); + + const trustMap = new Map(trustStates?.map((t) => [t.user_id, t]) || []); + + const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + + const { data: recentBlocks } = await serviceClient + .from('blocks') + .select('blocked_id') + .in('blocked_id', authorIds) + .gte('created_at', oneDayAgo); + + const blocksMap = new Map(); + recentBlocks?.forEach((block) => { + blocksMap.set(block.blocked_id, (blocksMap.get(block.blocked_id) || 0) + 1); + }); + + const postIds = posts.map((p) => p.id); + + const { data: reports } = await serviceClient + .from('reports') + .select('target_id, reporter_id') + .eq('target_type', 'post') + .in('target_id', postIds); + + const trustedReportMap = new Map(); + const totalReportMap = new Map(); + + for (const report of reports || []) { + totalReportMap.set(report.target_id, (totalReportMap.get(report.target_id) || 0) + 1); + + const reporterTrust = trustMap.get(report.reporter_id); + if (reporterTrust && reporterTrust.harmony_score >= 70) { + trustedReportMap.set(report.target_id, (trustedReportMap.get(report.target_id) || 0) + 1); + } + } + + // 7. Filter out posts with safety issues + const safePosts = posts.filter((post) => { + const blocksReceived = blocksMap.get(post.author_id) || 0; + const trustedReports = trustedReportMap.get(post.id) || 0; + + // Exclude if: + // - Author received 2+ blocks in 24h + // - Post has any trusted reports + return blocksReceived < 2 && trustedReports === 0; + }); + + // 8. Transform and rank + const postsForRanking: PostForRanking[] = safePosts.map((post) => { + const authorTrust = trustMap.get(post.author_id); + + return { + id: post.id, + created_at: post.created_at, + cis_score: post.cis_score || 0.8, + tone_label: post.tone_label || 'neutral', + save_count: post.metrics?.save_count || 0, + like_count: post.metrics?.like_count || 0, + view_count: post.metrics?.view_count || 0, + author_harmony_score: authorTrust?.harmony_score || 50, + author_tier: authorTrust?.tier || 'new', + blocks_received_24h: blocksMap.get(post.author_id) || 0, + trusted_reports: trustedReportMap.get(post.id) || 0, + total_reports: totalReportMap.get(post.id) || 0, + }; + }); + + const rankedPosts = rankPosts(postsForRanking); + + // 9. Take top N algorithmic posts + const topPosts = rankedPosts.slice(0, limit - overridePosts.length); + + // 10. Fetch full data for algorithmic posts + const algorithmicIds = topPosts.map((p) => p.id); + + const { data: algorithmicPosts } = await supabase + .from('posts') + .select( + ` + id, + body, + created_at, + tone_label, + allow_chain, + chain_parent_id, + chain_parent:posts!posts_chain_parent_id_fkey ( + id, + body, + created_at, + author:profiles!posts_author_id_fkey ( + id, + handle, + display_name, + avatar_url + ) + ), + author:profiles!posts_author_id_fkey ( + id, + handle, + display_name, + avatar_url + ), + category:categories!posts_category_id_fkey ( + id, + slug, + name + ), + metrics:post_metrics ( + like_count, + save_count + ) + ` + ) + .in('id', algorithmicIds); + + const algorithmicWithFlag = algorithmicPosts?.map((p) => ({ ...p, is_editorial: false })) || []; + + // 11. Merge editorial overrides first, then algorithmic + const trendingPosts = [...overridePosts, ...algorithmicWithFlag]; + + return new Response( + JSON.stringify({ + category: { + id: category.id, + slug: category.slug, + name: category.name, + }, + posts: trendingPosts, + explanation: + 'Trending shows calm resonance: steady saves and appreciation from trusted accounts. Editorial picks are marked. Nothing trends forever.', + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + } catch (error) { + console.error('Unexpected error:', error); + return new Response(JSON.stringify({ error: 'Internal server error' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +}); diff --git a/_legacy/supabase/functions/tsconfig.json b/_legacy/supabase/functions/tsconfig.json new file mode 100644 index 0000000..65c7a6a --- /dev/null +++ b/_legacy/supabase/functions/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "allowJs": true, + "moduleResolution": "node", + "types": ["node"], + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true + }, + "include": ["**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/_legacy/supabase/functions/upload-image/config.toml b/_legacy/supabase/functions/upload-image/config.toml new file mode 100644 index 0000000..4d86786 --- /dev/null +++ b/_legacy/supabase/functions/upload-image/config.toml @@ -0,0 +1 @@ +verify_jwt = false diff --git a/_legacy/supabase/functions/upload-image/index.ts b/_legacy/supabase/functions/upload-image/index.ts new file mode 100644 index 0000000..abe3009 --- /dev/null +++ b/_legacy/supabase/functions/upload-image/index.ts @@ -0,0 +1,150 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts" +import { AwsClient } from 'https://esm.sh/aws4fetch@1.0.17' +import { trySignR2Url } from "../_shared/r2_signer.ts"; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +} + +serve(async (req) => { + if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders }) + + try { + // 1. AUTH CHECK + const authHeader = req.headers.get('Authorization') + if (!authHeader) { + return new Response(JSON.stringify({ code: 401, message: 'Missing authorization header' }), { + status: 401, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }) + } + + // Extract user ID from JWT without full validation + // The JWT is already validated by Supabase's edge runtime + let userId: string + try { + const token = authHeader.replace('Bearer ', '') + const payload = JSON.parse(atob(token.split('.')[1])) + userId = payload.sub + if (!userId) throw new Error('No user ID in token') + console.log('Authenticated user:', userId) + } catch (e) { + console.error('Failed to parse JWT:', e) + return new Response(JSON.stringify({ code: 401, message: 'Invalid JWT' }), { + status: 401, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }) + } + + // 2. CONFIGURATION + const R2_BUCKET = 'sojorn-media' + const ACCOUNT_ID = (Deno.env.get('R2_ACCOUNT_ID') ?? '').trim() + const ACCESS_KEY = (Deno.env.get('R2_ACCESS_KEY') ?? '').trim() + const SECRET_KEY = (Deno.env.get('R2_SECRET_KEY') ?? '').trim() + if (!ACCOUNT_ID || !ACCESS_KEY || !SECRET_KEY) throw new Error('Missing R2 Secrets') + + // 3. PARSE MULTIPART FORM DATA (image + metadata) + const contentType = req.headers.get('content-type') || '' + + if (!contentType.includes('multipart/form-data')) { + throw new Error('Request must be multipart/form-data') + } + + const formData = await req.formData() + const imageFile = formData.get('image') as File + const fileName = formData.get('fileName') as string + + if (!imageFile) { + throw new Error('No image file provided') + } + + // Extract and sanitize extension from filename + let extension = 'jpg'; + if (fileName) { + const parts = fileName.split('.'); + if (parts.length > 1) { + const ext = parts[parts.length - 1].toLowerCase(); + // Only allow safe image extensions + if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext)) { + extension = ext; + } + } + } + const safeFileName = `${crypto.randomUUID()}.${extension}` + const imageContentType = imageFile.type || 'application/octet-stream' + + console.log(`Direct upload: fileName=${fileName}, contentType=${imageContentType}, size=${imageFile.size}`) + + // 4. INIT R2 CLIENT + const r2 = new AwsClient({ + accessKeyId: ACCESS_KEY, + secretAccessKey: SECRET_KEY, + region: 'auto', + service: 's3', + }) + + // 5. UPLOAD DIRECTLY TO R2 FROM EDGE FUNCTION + const url = `https://${ACCOUNT_ID}.r2.cloudflarestorage.com/${R2_BUCKET}/${safeFileName}` + const imageBytes = await imageFile.arrayBuffer() + + const uploadResponse = await r2.fetch(url, { + method: 'PUT', + body: imageBytes, + headers: { + 'Content-Type': imageContentType, + 'Content-Length': imageBytes.byteLength.toString(), + }, + }) + + if (!uploadResponse.ok) { + const errorText = await uploadResponse.text() + console.error('R2 upload failed:', errorText) + throw new Error(`R2 upload failed: ${uploadResponse.status} ${errorText}`) + } + + console.log('Successfully uploaded to R2:', safeFileName) + + // 6. RETURN SUCCESS RESPONSE + // Always return a signed URL to avoid public bucket access. + const signedUrl = await trySignR2Url(safeFileName) + if (!signedUrl) { + throw new Error('Failed to generate signed URL') + } + + return new Response( + JSON.stringify({ + publicUrl: signedUrl, + signedUrl, + signed_url: signedUrl, + fileKey: safeFileName, + fileName: safeFileName, + fileSize: imageFile.size, + contentType: imageContentType, + }), + { + headers: { + ...corsHeaders, + 'Content-Type': 'application/json', + } + } + ) + + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + console.error('Upload function error:', errorMessage) + return new Response( + JSON.stringify({ + error: errorMessage, + hint: 'Make sure you are logged in and R2 credentials are configured.' + }), + { + status: 400, + headers: { + ...corsHeaders, + 'Content-Type': 'application/json' + } + } + ) + } +}) diff --git a/_legacy/supabase/functions/upload-media/index.ts b/_legacy/supabase/functions/upload-media/index.ts new file mode 100644 index 0000000..765f035 --- /dev/null +++ b/_legacy/supabase/functions/upload-media/index.ts @@ -0,0 +1,161 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts" +import { AwsClient } from 'https://esm.sh/aws4fetch@1.0.17' +import { trySignR2Url } from "../_shared/r2_signer.ts"; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +} + +serve(async (req) => { + if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders }) + + try { + // 1. AUTH CHECK + const authHeader = req.headers.get('Authorization') + if (!authHeader) { + return new Response(JSON.stringify({ code: 401, message: 'Missing authorization header' }), { + status: 401, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }) + } + + // Extract user ID from JWT without full validation + // The JWT is already validated by Supabase's edge runtime + let userId: string + try { + const token = authHeader.replace('Bearer ', '') + const payload = JSON.parse(atob(token.split('.')[1])) + userId = payload.sub + if (!userId) throw new Error('No user ID in token') + console.log('Authenticated user:', userId) + } catch (e) { + console.error('Failed to parse JWT:', e) + return new Response(JSON.stringify({ code: 401, message: 'Invalid JWT' }), { + status: 401, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }) + } + + // 2. CONFIGURATION + const R2_BUCKET_IMAGES = 'sojorn-media' + const R2_BUCKET_VIDEOS = 'sojorn-videos' + const ACCOUNT_ID = (Deno.env.get('R2_ACCOUNT_ID') ?? '').trim() + const ACCESS_KEY = (Deno.env.get('R2_ACCESS_KEY') ?? '').trim() + const SECRET_KEY = (Deno.env.get('R2_SECRET_KEY') ?? '').trim() + if (!ACCOUNT_ID || !ACCESS_KEY || !SECRET_KEY) throw new Error('Missing R2 Secrets') + + // 3. PARSE MULTIPART FORM DATA + const contentType = req.headers.get('content-type') || '' + + if (!contentType.includes('multipart/form-data')) { + throw new Error('Request must be multipart/form-data') + } + + const formData = await req.formData() + const mediaFile = formData.get('media') as File + const fileName = formData.get('fileName') as string + const mediaType = formData.get('type') as string + + if (!mediaFile) { + throw new Error('No media file provided') + } + + if (!mediaType || (mediaType !== 'image' && mediaType !== 'video')) { + throw new Error('Invalid or missing type parameter. Must be "image" or "video"') + } + + // Extract and sanitize extension from filename + let extension = mediaType === 'image' ? 'jpg' : 'mp4' + if (fileName) { + const parts = fileName.split('.') + if (parts.length > 1) { + const ext = parts[parts.length - 1].toLowerCase() + // Only allow safe extensions + if (mediaType === 'image' && ['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext)) { + extension = ext + } else if (mediaType === 'video' && ['mp4', 'mov', 'webm'].includes(ext)) { + extension = ext + } + } + } + + const safeFileName = `${crypto.randomUUID()}.${extension}` + const mediaContentType = mediaFile.type || (mediaType === 'image' ? 'image/jpeg' : 'video/mp4') + + console.log(`Direct upload: type=${mediaType}, fileName=${fileName}, contentType=${mediaContentType}, size=${mediaFile.size}`) + + // 4. INIT R2 CLIENT + const r2 = new AwsClient({ + accessKeyId: ACCESS_KEY, + secretAccessKey: SECRET_KEY, + region: 'auto', + service: 's3', + }) + + // 5. UPLOAD DIRECTLY TO R2 FROM EDGE FUNCTION + const bucket = mediaType === 'image' ? R2_BUCKET_IMAGES : R2_BUCKET_VIDEOS + const url = `https://${ACCOUNT_ID}.r2.cloudflarestorage.com/${bucket}/${safeFileName}` + const mediaBytes = await mediaFile.arrayBuffer() + + const uploadResponse = await r2.fetch(url, { + method: 'PUT', + body: mediaBytes, + headers: { + 'Content-Type': mediaContentType, + 'Content-Length': mediaBytes.byteLength.toString(), + }, + }) + + if (!uploadResponse.ok) { + const errorText = await uploadResponse.text() + console.error('R2 upload failed:', errorText) + throw new Error(`R2 upload failed: ${uploadResponse.status} ${errorText}`) + } + + console.log('Successfully uploaded to R2:', safeFileName) + + // 6. RETURN SUCCESS RESPONSE + // Always return a signed URL to avoid public bucket access. + const signedUrl = await trySignR2Url(safeFileName, bucket) + if (!signedUrl) { + throw new Error('Failed to generate signed URL') + } + + return new Response( + JSON.stringify({ + publicUrl: signedUrl, + signedUrl, + signed_url: signedUrl, + fileKey: safeFileName, + fileName: safeFileName, + fileSize: mediaFile.size, + contentType: mediaContentType, + type: mediaType, + }), + { + headers: { + ...corsHeaders, + 'Content-Type': 'application/json', + } + } + ) + + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + console.error('Upload function error:', errorMessage) + return new Response( + JSON.stringify({ + error: errorMessage, + hint: 'Make sure you are logged in and R2 credentials are configured.' + }), + { + status: 400, + headers: { + ...corsHeaders, + 'Content-Type': 'application/json' + } + } + ) + } +}) diff --git a/_legacy/supabase/migrations/20260112_hashtags_and_fts_posts.sql b/_legacy/supabase/migrations/20260112_hashtags_and_fts_posts.sql new file mode 100644 index 0000000..5106ee5 --- /dev/null +++ b/_legacy/supabase/migrations/20260112_hashtags_and_fts_posts.sql @@ -0,0 +1,17 @@ +-- Migration: Add hashtag + full-text search support to posts +-- Created: 2026-01-12 +-- Purpose: Make category optional while enabling tag storage and search vectors + +-- 1) Make category optional to remove posting friction +alter table posts + alter column category_id drop not null; + +-- 2) Store extracted hashtags and full-text search vector +alter table posts + add column if not exists tags text[] default '{}'::text[], + add column if not exists fts tsvector + generated always as (to_tsvector('english', coalesce(body, ''))) + stored; + +-- 3) Index for fast full-text search lookups +create index if not exists idx_posts_fts on posts using gin (fts); diff --git a/_legacy/supabase/migrations/20260113_post_ttl.sql b/_legacy/supabase/migrations/20260113_post_ttl.sql new file mode 100644 index 0000000..f2b0f80 --- /dev/null +++ b/_legacy/supabase/migrations/20260113_post_ttl.sql @@ -0,0 +1,11 @@ +-- Privacy-first TTL support for posts +-- default_post_ttl is in hours; NULL means keep forever. + +alter table if exists user_settings + add column if not exists default_post_ttl integer; + +alter table if exists posts + add column if not exists expires_at timestamptz; + +create index if not exists posts_expires_at_idx + on posts (expires_at); diff --git a/_legacy/supabase/migrations/20260113_security_remediations.sql b/_legacy/supabase/migrations/20260113_security_remediations.sql new file mode 100644 index 0000000..0a8cfa5 --- /dev/null +++ b/_legacy/supabase/migrations/20260113_security_remediations.sql @@ -0,0 +1,34 @@ +-- Security lint remediations +-- 1) Make view_searchable_tags SECURITY INVOKER (avoid definer semantics) +create or replace view view_searchable_tags +with (security_invoker = true) as +select + unnest(tags) as tag, + count(*) as count +from posts +where deleted_at is null + and tags is not null + and array_length(tags, 1) > 0 +group by unnest(tags) +order by count desc; + +-- 2) Enable RLS on notifications with per-user visibility +alter table if exists notifications enable row level security; +drop policy if exists "Users can view own notifications" on notifications; +create policy "Users can view own notifications" on notifications + for select + using (user_id = auth.uid()); + +-- Allow inserts/updates/deletes via service role (if your functions need it) +drop policy if exists "Service role manages notifications" on notifications; +create policy "Service role manages notifications" on notifications + for all + using (auth.role() = 'service_role') + with check (auth.role() = 'service_role'); + +-- 3) Enforce RLS on spatial_ref_sys unconditionally (run as owner/superuser) +alter table spatial_ref_sys enable row level security; +drop policy if exists "Public read spatial_ref_sys" on spatial_ref_sys; +create policy "Public read spatial_ref_sys" on spatial_ref_sys + for select + using (true); diff --git a/_legacy/supabase/migrations/20260114_add_profile_role.sql b/_legacy/supabase/migrations/20260114_add_profile_role.sql new file mode 100644 index 0000000..3112fc5 --- /dev/null +++ b/_legacy/supabase/migrations/20260114_add_profile_role.sql @@ -0,0 +1,15 @@ +alter table profiles + add column if not exists role text not null default 'user'; + +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conname = 'profiles_role_check' + ) then + alter table profiles + add constraint profiles_role_check + check (role in ('user', 'moderator', 'admin', 'banned')); + end if; +end $$; diff --git a/_legacy/supabase/migrations/20260114_admin_rls_policy.sql b/_legacy/supabase/migrations/20260114_admin_rls_policy.sql new file mode 100644 index 0000000..66fc13c --- /dev/null +++ b/_legacy/supabase/migrations/20260114_admin_rls_policy.sql @@ -0,0 +1,18 @@ +do $$ +begin + if not exists ( + select 1 + from pg_policies + where schemaname = 'public' + and tablename = 'posts' + and policyname = 'Admins can see everything' + ) then + create policy "Admins can see everything" + on posts + for select + to authenticated + using ( + (select role from profiles where id = auth.uid()) in ('admin', 'moderator') + ); + end if; +end $$; diff --git a/_legacy/supabase/migrations/20260114_beacon_vouch_ttl.sql b/_legacy/supabase/migrations/20260114_beacon_vouch_ttl.sql new file mode 100644 index 0000000..58ce28e --- /dev/null +++ b/_legacy/supabase/migrations/20260114_beacon_vouch_ttl.sql @@ -0,0 +1,59 @@ +-- Auto-expire beacons 12 hours after the most recent vouch + +create or replace function update_beacon_expires_at(p_beacon_id uuid) +returns void +language plpgsql +security definer +as $$ +declare + last_vouch_at timestamptz; +begin + select max(created_at) + into last_vouch_at + from beacon_votes + where beacon_id = p_beacon_id + and vote_type = 'vouch'; + + update posts + set expires_at = case + when last_vouch_at is null then null + else last_vouch_at + interval '12 hours' + end + where id = p_beacon_id + and is_beacon = true; +end; +$$; + +create or replace function handle_beacon_vote_ttl() +returns trigger +language plpgsql +security definer +as $$ +declare + target_beacon_id uuid; +begin + target_beacon_id := coalesce(new.beacon_id, old.beacon_id); + if target_beacon_id is not null then + perform update_beacon_expires_at(target_beacon_id); + end if; + return null; +end; +$$; + +drop trigger if exists beacon_vote_ttl_trigger on beacon_votes; +create trigger beacon_vote_ttl_trigger +after insert or update or delete on beacon_votes +for each row +execute function handle_beacon_vote_ttl(); + +-- Backfill existing beacons with vouches +update posts p +set expires_at = v.last_vouch_at + interval '12 hours' +from ( + select beacon_id, max(created_at) as last_vouch_at + from beacon_votes + where vote_type = 'vouch' + group by beacon_id +) v +where p.id = v.beacon_id + and p.is_beacon = true; diff --git a/_legacy/supabase/migrations/20260114_zero_tolerance_moderation.sql b/_legacy/supabase/migrations/20260114_zero_tolerance_moderation.sql new file mode 100644 index 0000000..3b2126d --- /dev/null +++ b/_legacy/supabase/migrations/20260114_zero_tolerance_moderation.sql @@ -0,0 +1,18 @@ +alter table profiles + add column if not exists strikes integer not null default 0; + +alter table posts + add column if not exists moderation_status text not null default 'approved'; + +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conname = 'posts_moderation_status_check' + ) then + alter table posts + add constraint posts_moderation_status_check + check (moderation_status in ('approved', 'flagged_bigotry', 'flagged_nsfw', 'rejected')); + end if; +end $$; diff --git a/_legacy/supabase/migrations/20260117_add_origin_country.sql b/_legacy/supabase/migrations/20260117_add_origin_country.sql new file mode 100644 index 0000000..6797211 --- /dev/null +++ b/_legacy/supabase/migrations/20260117_add_origin_country.sql @@ -0,0 +1,14 @@ +-- Add origin_country column to profiles table +-- Stores ISO 3166-1 alpha-2 country code (e.g., 'US', 'GB', 'CA') +-- Captured automatically at signup via ipinfo.io geolocation lookup + +ALTER TABLE profiles +ADD COLUMN IF NOT EXISTS origin_country TEXT; + +-- Add constraint for valid ISO 2-letter code format +ALTER TABLE profiles +ADD CONSTRAINT profiles_origin_country_format +CHECK (origin_country IS NULL OR origin_country ~ '^[A-Z]{2}$'); + +-- Add comment for documentation +COMMENT ON COLUMN profiles.origin_country IS 'ISO 3166-1 alpha-2 country code captured at signup via ipinfo.io geolocation'; diff --git a/_legacy/supabase/migrations/20260117_private_follow_model.sql b/_legacy/supabase/migrations/20260117_private_follow_model.sql new file mode 100644 index 0000000..fb9426f --- /dev/null +++ b/_legacy/supabase/migrations/20260117_private_follow_model.sql @@ -0,0 +1,174 @@ +-- Private-by-default follow model + mutuals enforcement + +-- 1) Profiles: add privacy/official flags +alter table if exists profiles + add column if not exists is_private boolean not null default true, + add column if not exists is_official boolean not null default false; + +-- 2) Follows: add status and constraint +alter table if exists follows + add column if not exists status text not null default 'accepted'; + +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conname = 'follows_status_check' + ) then + alter table follows + add constraint follows_status_check + check (status in ('pending', 'accepted')); + end if; +end $$; + +-- 3) Request follow function (privacy-aware) +create or replace function request_follow(target_id uuid) +returns text +language plpgsql +security definer +as $$ +declare + existing_status text; + target_private boolean; + target_official boolean; + new_status text; +begin + if auth.uid() is null then + raise exception 'Not authenticated'; + end if; + + select status into existing_status + from follows + where follower_id = auth.uid() + and following_id = target_id; + + if existing_status is not null then + return existing_status; + end if; + + select is_private, is_official + into target_private, target_official + from profiles + where id = target_id; + + if target_private is null then + raise exception 'Target profile not found'; + end if; + + if target_official or target_private = false then + new_status := 'accepted'; + else + new_status := 'pending'; + end if; + + insert into follows (follower_id, following_id, status) + values (auth.uid(), target_id, new_status); + + return new_status; +end; +$$; + +-- 4) Mutual follow must be accepted on both sides +create or replace function is_mutual_follow(user_a uuid, user_b uuid) +returns boolean +language plpgsql +security definer +as $$ +begin + return exists ( + select 1 + from follows f1 + where f1.follower_id = user_a + and f1.following_id = user_b + and f1.status = 'accepted' + ) and exists ( + select 1 + from follows f2 + where f2.follower_id = user_b + and f2.following_id = user_a + and f2.status = 'accepted' + ); +end; +$$; + +-- 5) Follow request management helpers +create or replace function accept_follow_request(requester_id uuid) +returns void +language plpgsql +security definer +as $$ +begin + if auth.uid() is null then + raise exception 'Not authenticated'; + end if; + + update follows + set status = 'accepted' + where follower_id = requester_id + and following_id = auth.uid(); +end; +$$; + +create or replace function reject_follow_request(requester_id uuid) +returns void +language plpgsql +security definer +as $$ +begin + if auth.uid() is null then + raise exception 'Not authenticated'; + end if; + + delete from follows + where follower_id = requester_id + and following_id = auth.uid(); +end; +$$; + +create or replace function get_follow_requests() +returns table ( + follower_id uuid, + handle text, + display_name text, + avatar_url text, + requested_at timestamptz +) +language sql +security definer +as $$ + select + f.follower_id, + p.handle, + p.display_name, + p.avatar_url, + f.created_at as requested_at + from follows f + join profiles p on p.id = f.follower_id + where f.following_id = auth.uid() + and f.status = 'pending' + order by f.created_at desc; +$$; + +-- 6) Posts RLS: allow self, public, or accepted follow +alter table if exists posts enable row level security; + +drop policy if exists posts_select_private_model on posts; +create policy posts_select_private_model on posts + for select + using ( + auth.uid() = author_id + or exists ( + select 1 + from profiles p + where p.id = author_id + and p.is_private = false + ) + or exists ( + select 1 + from follows f + where f.follower_id = auth.uid() + and f.following_id = author_id + and f.status = 'accepted' + ) + ); diff --git a/_legacy/supabase/migrations/20260117_secure_e2ee_chat.sql b/_legacy/supabase/migrations/20260117_secure_e2ee_chat.sql new file mode 100644 index 0000000..309c18a --- /dev/null +++ b/_legacy/supabase/migrations/20260117_secure_e2ee_chat.sql @@ -0,0 +1,305 @@ +-- ============================================================================ +-- Secure E2EE Chat System for Mutual Follows +-- ============================================================================ +-- This migration creates the infrastructure for end-to-end encrypted messaging +-- using Signal Protocol concepts. Only mutual follows can exchange messages. +-- The server never sees plaintext - only encrypted blobs. +-- ============================================================================ + +-- ============================================================================ +-- 1. Signal Protocol Key Storage +-- ============================================================================ + +-- Identity and pre-keys for Signal Protocol key exchange +CREATE TABLE IF NOT EXISTS signal_keys ( + user_id UUID PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE, + + -- Identity Key (long-term, base64 encoded public key) + identity_key_public TEXT NOT NULL, + + -- Signed Pre-Key (medium-term, rotated periodically) + signed_prekey_public TEXT NOT NULL, + signed_prekey_id INTEGER NOT NULL DEFAULT 1, + signed_prekey_signature TEXT NOT NULL, + + -- One-Time Pre-Keys (for perfect forward secrecy, consumed on use) + -- Stored as JSONB array: [{"id": 1, "key": "base64..."}, ...] + one_time_prekeys JSONB DEFAULT '[]'::JSONB, + + -- Metadata + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Index for fast key lookups +CREATE INDEX IF NOT EXISTS idx_signal_keys_user_id ON signal_keys(user_id); + +-- ============================================================================ +-- 2. Encrypted Conversations Metadata +-- ============================================================================ + +-- Conversation metadata (no content, just participants and state) +CREATE TABLE IF NOT EXISTS encrypted_conversations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Participants (always 2 for DM) + participant_a UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + participant_b UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + + -- Conversation state + created_at TIMESTAMPTZ DEFAULT NOW(), + last_message_at TIMESTAMPTZ DEFAULT NOW(), + + -- Ensure ordered participant storage (smaller UUID first) + -- This prevents duplicate conversations + CONSTRAINT ordered_participants CHECK (participant_a < participant_b), + CONSTRAINT unique_conversation UNIQUE (participant_a, participant_b) +); + +-- Indexes for conversation lookups +CREATE INDEX IF NOT EXISTS idx_conversations_participant_a ON encrypted_conversations(participant_a); +CREATE INDEX IF NOT EXISTS idx_conversations_participant_b ON encrypted_conversations(participant_b); +CREATE INDEX IF NOT EXISTS idx_conversations_last_message ON encrypted_conversations(last_message_at DESC); + +-- ============================================================================ +-- 3. Encrypted Messages +-- ============================================================================ + +-- Encrypted message storage - server sees ONLY ciphertext +CREATE TABLE IF NOT EXISTS encrypted_messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Conversation reference + conversation_id UUID NOT NULL REFERENCES encrypted_conversations(id) ON DELETE CASCADE, + + -- Sender (for routing, not content attribution) + sender_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + + -- Encrypted payload (what the server stores) + -- This is the Signal Protocol message, completely opaque to server + ciphertext BYTEA NOT NULL, + + -- Signal Protocol header (needed for decryption, but reveals nothing) + -- Contains ephemeral key, previous chain length, message number + message_header TEXT NOT NULL, + + -- Message type (for protocol handling) + -- 1 = PreKeyWhisperMessage (initial message establishing session) + -- 2 = WhisperMessage (subsequent messages in established session) + message_type INTEGER NOT NULL DEFAULT 2, + + -- Delivery metadata + created_at TIMESTAMPTZ DEFAULT NOW(), + delivered_at TIMESTAMPTZ, + read_at TIMESTAMPTZ, + + -- Expiration (optional ephemeral messaging) + expires_at TIMESTAMPTZ +); + +-- Indexes for message retrieval +CREATE INDEX IF NOT EXISTS idx_messages_conversation ON encrypted_messages(conversation_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_messages_sender ON encrypted_messages(sender_id); +CREATE INDEX IF NOT EXISTS idx_messages_unread ON encrypted_messages(conversation_id, read_at) WHERE read_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_messages_expiring ON encrypted_messages(expires_at) WHERE expires_at IS NOT NULL; + +-- ============================================================================ +-- 4. Helper Functions +-- ============================================================================ + +-- Check if two users have a mutual follow relationship +CREATE OR REPLACE FUNCTION is_mutual_follow(user_a UUID, user_b UUID) +RETURNS BOOLEAN AS $$ +BEGIN + RETURN EXISTS ( + SELECT 1 FROM follows f1 + WHERE f1.follower_id = user_a + AND f1.following_id = user_b + ) AND EXISTS ( + SELECT 1 FROM follows f2 + WHERE f2.follower_id = user_b + AND f2.following_id = user_a + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Get or create a conversation between two mutual follows +CREATE OR REPLACE FUNCTION get_or_create_conversation(user_a UUID, user_b UUID) +RETURNS UUID AS $$ +DECLARE + conv_id UUID; + ordered_a UUID; + ordered_b UUID; +BEGIN + -- Verify mutual follow + IF NOT is_mutual_follow(user_a, user_b) THEN + RAISE EXCEPTION 'Users must have mutual follow to start conversation'; + END IF; + + -- Order participants for consistent storage + IF user_a < user_b THEN + ordered_a := user_a; + ordered_b := user_b; + ELSE + ordered_a := user_b; + ordered_b := user_a; + END IF; + + -- Try to get existing conversation + SELECT id INTO conv_id + FROM encrypted_conversations + WHERE participant_a = ordered_a AND participant_b = ordered_b; + + -- Create if doesn't exist + IF conv_id IS NULL THEN + INSERT INTO encrypted_conversations (participant_a, participant_b) + VALUES (ordered_a, ordered_b) + RETURNING id INTO conv_id; + END IF; + + RETURN conv_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Consume a one-time pre-key (returns and removes it atomically) +CREATE OR REPLACE FUNCTION consume_one_time_prekey(target_user_id UUID) +RETURNS JSONB AS $$ +DECLARE + prekey JSONB; + remaining JSONB; +BEGIN + -- Get the first prekey + SELECT one_time_prekeys->0 INTO prekey + FROM signal_keys + WHERE user_id = target_user_id + AND jsonb_array_length(one_time_prekeys) > 0 + FOR UPDATE; + + IF prekey IS NULL THEN + RETURN NULL; + END IF; + + -- Remove it from the array + UPDATE signal_keys + SET one_time_prekeys = one_time_prekeys - 0, + updated_at = NOW() + WHERE user_id = target_user_id; + + RETURN prekey; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- ============================================================================ +-- 5. Row Level Security Policies +-- ============================================================================ + +-- Enable RLS on all tables +ALTER TABLE signal_keys ENABLE ROW LEVEL SECURITY; +ALTER TABLE encrypted_conversations ENABLE ROW LEVEL SECURITY; +ALTER TABLE encrypted_messages ENABLE ROW LEVEL SECURITY; + +-- Signal Keys: Users can only manage their own keys +CREATE POLICY signal_keys_select ON signal_keys + FOR SELECT USING (true); -- Anyone can read public keys + +CREATE POLICY signal_keys_insert ON signal_keys + FOR INSERT WITH CHECK (auth.uid() = user_id); + +CREATE POLICY signal_keys_update ON signal_keys + FOR UPDATE USING (auth.uid() = user_id); + +CREATE POLICY signal_keys_delete ON signal_keys + FOR DELETE USING (auth.uid() = user_id); + +-- Conversations: Only participants can see their conversations +CREATE POLICY conversations_select ON encrypted_conversations + FOR SELECT USING ( + auth.uid() = participant_a OR auth.uid() = participant_b + ); + +-- Conversations are created via the get_or_create_conversation function +-- which enforces mutual follow, so we allow insert if user is a participant +CREATE POLICY conversations_insert ON encrypted_conversations + FOR INSERT WITH CHECK ( + (auth.uid() = participant_a OR auth.uid() = participant_b) + AND is_mutual_follow(participant_a, participant_b) + ); + +-- Messages: Only conversation participants can see/send messages +CREATE POLICY messages_select ON encrypted_messages + FOR SELECT USING ( + EXISTS ( + SELECT 1 FROM encrypted_conversations c + WHERE c.id = conversation_id + AND (c.participant_a = auth.uid() OR c.participant_b = auth.uid()) + ) + ); + +-- Critical: Messages can only be inserted by sender who is in a mutual follow +CREATE POLICY messages_insert ON encrypted_messages + FOR INSERT WITH CHECK ( + auth.uid() = sender_id + AND EXISTS ( + SELECT 1 FROM encrypted_conversations c + WHERE c.id = conversation_id + AND (c.participant_a = auth.uid() OR c.participant_b = auth.uid()) + AND is_mutual_follow(c.participant_a, c.participant_b) + ) + ); + +-- Users can update their own sent messages (for read receipts on received messages) +CREATE POLICY messages_update ON encrypted_messages + FOR UPDATE USING ( + EXISTS ( + SELECT 1 FROM encrypted_conversations c + WHERE c.id = conversation_id + AND (c.participant_a = auth.uid() OR c.participant_b = auth.uid()) + ) + ); + +-- ============================================================================ +-- 6. Triggers for Metadata Updates +-- ============================================================================ + +-- Update conversation last_message_at when new message is inserted +CREATE OR REPLACE FUNCTION update_conversation_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE encrypted_conversations + SET last_message_at = NEW.created_at + WHERE id = NEW.conversation_id; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_update_conversation_timestamp + AFTER INSERT ON encrypted_messages + FOR EACH ROW + EXECUTE FUNCTION update_conversation_timestamp(); + +-- Auto-expire old messages (for ephemeral messaging) +CREATE OR REPLACE FUNCTION cleanup_expired_messages() +RETURNS void AS $$ +BEGIN + DELETE FROM encrypted_messages + WHERE expires_at IS NOT NULL AND expires_at < NOW(); +END; +$$ LANGUAGE plpgsql; + +-- ============================================================================ +-- 7. Realtime Subscriptions +-- ============================================================================ + +-- Enable realtime for messages (participants will subscribe to their conversations) +ALTER PUBLICATION supabase_realtime ADD TABLE encrypted_messages; + +-- ============================================================================ +-- 8. Comments for Documentation +-- ============================================================================ + +COMMENT ON TABLE signal_keys IS 'Public cryptographic keys for Signal Protocol key exchange. Private keys stored only on device.'; +COMMENT ON TABLE encrypted_conversations IS 'Metadata for E2EE conversations. No message content stored here.'; +COMMENT ON TABLE encrypted_messages IS 'Encrypted message blobs. Server cannot decrypt - only route.'; +COMMENT ON FUNCTION is_mutual_follow IS 'Returns true if both users follow each other.'; +COMMENT ON FUNCTION get_or_create_conversation IS 'Creates or retrieves a conversation, enforcing mutual follow requirement.'; +COMMENT ON FUNCTION consume_one_time_prekey IS 'Atomically retrieves and removes a one-time pre-key for forward secrecy.'; diff --git a/_legacy/supabase/migrations/20260118_fix_notifications_type_check.sql b/_legacy/supabase/migrations/20260118_fix_notifications_type_check.sql new file mode 100644 index 0000000..fd790f8 --- /dev/null +++ b/_legacy/supabase/migrations/20260118_fix_notifications_type_check.sql @@ -0,0 +1,26 @@ +-- Fix notifications type check to allow new follow-related types + +do $$ +begin + if exists ( + select 1 + from pg_constraint + where conname = 'notifications_type_check' + ) then + alter table notifications + drop constraint notifications_type_check; + end if; +end $$; + +alter table notifications + add constraint notifications_type_check + check (type in ( + 'appreciate', + 'chain', + 'follow', + 'comment', + 'mention', + 'follow_request', + 'new_follower', + 'request_accepted' + )); diff --git a/_legacy/supabase/migrations/20260118_fix_secure_chat_rls.sql b/_legacy/supabase/migrations/20260118_fix_secure_chat_rls.sql new file mode 100644 index 0000000..00b0139 --- /dev/null +++ b/_legacy/supabase/migrations/20260118_fix_secure_chat_rls.sql @@ -0,0 +1,65 @@ +-- Fix RLS policies for secure chat messages to ensure participants can access them. + +-- 1. Ensure RLS is enabled on the encrypted_messages table. +ALTER TABLE public.encrypted_messages ENABLE ROW LEVEL SECURITY; + +-- 2. Policy for SELECTing messages. +-- Allows a user to read messages in conversations they are a participant in. +DROP POLICY IF EXISTS "Allow participants to read messages" ON public.encrypted_messages; +CREATE POLICY "Allow participants to read messages" +ON public.encrypted_messages FOR SELECT USING ( + exists ( + select 1 + from public.encrypted_conversations + where id = encrypted_messages.conversation_id + and ( + participant_a = auth.uid() or + participant_b = auth.uid() + ) + ) +); + +-- 3. Policy for INSERTing new messages. +-- Allows a user to insert a message if they are the sender and a participant +-- in the conversation. +DROP POLICY IF EXISTS "Allow participants to send messages" ON public.encrypted_messages; +CREATE POLICY "Allow participants to send messages" +ON public.encrypted_messages FOR INSERT WITH CHECK ( + sender_id = auth.uid() AND + exists ( + select 1 + from public.encrypted_conversations + where id = encrypted_messages.conversation_id + and ( + participant_a = auth.uid() or + participant_b = auth.uid() + ) + ) +); + +-- 4. Policy for UPDATing messages (e.g., marking as read). +-- Allows a participant to update a message in their conversation. +-- The client-side code should enforce that only the recipient can mark as read. +DROP POLICY IF EXISTS "Allow participants to update messages" ON public.encrypted_messages; +CREATE POLICY "Allow participants to update messages" +ON public.encrypted_messages FOR UPDATE USING ( + exists ( + select 1 + from public.encrypted_conversations + where id = encrypted_messages.conversation_id + and ( + participant_a = auth.uid() or + participant_b = auth.uid() + ) + ) +) WITH CHECK ( + exists ( + select 1 + from public.encrypted_conversations + where id = encrypted_messages.conversation_id + and ( + participant_a = auth.uid() or + participant_b = auth.uid() + ) + ) +); diff --git a/_legacy/supabase/migrations/20260118_follow_guardrails.sql b/_legacy/supabase/migrations/20260118_follow_guardrails.sql new file mode 100644 index 0000000..86fa3e0 --- /dev/null +++ b/_legacy/supabase/migrations/20260118_follow_guardrails.sql @@ -0,0 +1,55 @@ +-- Follow guardrails: prevent self-follow and block-based follows + +create or replace function request_follow(target_id uuid) +returns text +language plpgsql +security definer +as $$ +declare + existing_status text; + target_private boolean; + target_official boolean; + new_status text; +begin + if auth.uid() is null then + raise exception 'Not authenticated'; + end if; + + if target_id is null then + raise exception 'Target profile not found'; + end if; + + if auth.uid() = target_id then + raise exception 'Cannot follow yourself'; + end if; + + select status into existing_status + from follows + where follower_id = auth.uid() + and following_id = target_id; + + if existing_status is not null then + return existing_status; + end if; + + select is_private, is_official + into target_private, target_official + from profiles + where id = target_id; + + if target_private is null then + raise exception 'Target profile not found'; + end if; + + if target_official or target_private = false then + new_status := 'accepted'; + else + new_status := 'pending'; + end if; + + insert into follows (follower_id, following_id, status) + values (auth.uid(), target_id, new_status); + + return new_status; +end; +$$; diff --git a/_legacy/supabase/migrations/20260118_follow_notifications.sql b/_legacy/supabase/migrations/20260118_follow_notifications.sql new file mode 100644 index 0000000..d9d8249 --- /dev/null +++ b/_legacy/supabase/migrations/20260118_follow_notifications.sql @@ -0,0 +1,67 @@ +-- Follow notifications: trigger + metadata + +alter table if exists notifications + add column if not exists metadata jsonb not null default '{}'::jsonb; + +do $$ +begin + alter type notification_type add value if not exists 'follow_request'; + alter type notification_type add value if not exists 'new_follower'; + alter type notification_type add value if not exists 'request_accepted'; +end $$; + +create or replace function handle_follow_notification() +returns trigger +language plpgsql +as $$ +begin + if tg_op = 'INSERT' then + if new.status = 'pending' then + insert into notifications (user_id, type, actor_id, metadata) + values ( + new.following_id, + 'follow_request', + new.follower_id, + jsonb_build_object( + 'follower_id', new.follower_id, + 'following_id', new.following_id, + 'status', new.status + ) + ); + elsif new.status = 'accepted' then + insert into notifications (user_id, type, actor_id, metadata) + values ( + new.following_id, + 'new_follower', + new.follower_id, + jsonb_build_object( + 'follower_id', new.follower_id, + 'following_id', new.following_id, + 'status', new.status + ) + ); + end if; + elsif tg_op = 'UPDATE' then + if old.status = 'pending' and new.status = 'accepted' then + insert into notifications (user_id, type, actor_id, metadata) + values ( + new.follower_id, + 'request_accepted', + new.following_id, + jsonb_build_object( + 'follower_id', new.follower_id, + 'following_id', new.following_id, + 'status', new.status + ) + ); + end if; + end if; + + return new; +end; +$$; + +drop trigger if exists follow_notification_trigger on follows; +create trigger follow_notification_trigger +after insert or update on follows +for each row execute function handle_follow_notification(); diff --git a/_legacy/supabase/migrations/20260118_pinned_posts.sql b/_legacy/supabase/migrations/20260118_pinned_posts.sql new file mode 100644 index 0000000..78a3c93 --- /dev/null +++ b/_legacy/supabase/migrations/20260118_pinned_posts.sql @@ -0,0 +1,8 @@ +-- Allow one pinned post per author for profile feeds + +alter table if exists posts + add column if not exists pinned_at timestamptz; + +create unique index if not exists posts_author_pinned_unique + on posts (author_id) + where pinned_at is not null; diff --git a/_legacy/supabase/migrations/20260118_post_visibility.sql b/_legacy/supabase/migrations/20260118_post_visibility.sql new file mode 100644 index 0000000..b7dd0e3 --- /dev/null +++ b/_legacy/supabase/migrations/20260118_post_visibility.sql @@ -0,0 +1,39 @@ +-- Post-level visibility controls + +alter table if exists posts + add column if not exists visibility text not null default 'public'; + +update posts +set visibility = 'public' +where visibility is null; + +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conname = 'posts_visibility_check' + ) then + alter table posts + add constraint posts_visibility_check + check (visibility in ('public', 'followers', 'private')); + end if; +end $$; + +drop policy if exists posts_select_private_model on posts; +create policy posts_select_private_model on posts + for select + using ( + auth.uid() = author_id + or visibility = 'public' + or ( + visibility = 'followers' + and exists ( + select 1 + from follows f + where f.follower_id = auth.uid() + and f.following_id = author_id + and f.status = 'accepted' + ) + ) + ); diff --git a/_legacy/supabase/migrations/20260118_signal_protocol_schema_updates.sql b/_legacy/supabase/migrations/20260118_signal_protocol_schema_updates.sql new file mode 100644 index 0000000..346dce9 --- /dev/null +++ b/_legacy/supabase/migrations/20260118_signal_protocol_schema_updates.sql @@ -0,0 +1,109 @@ +-- ============================================================================ +-- Signal Protocol Schema Updates +-- ============================================================================ +-- Updates to support proper Signal Protocol implementation with separate +-- one_time_prekeys table and profiles identity key storage. +-- ============================================================================ + +-- ============================================================================ +-- 1. Update profiles table to store identity key and registration ID +-- ============================================================================ + +-- Add Signal Protocol identity key and registration ID to profiles +ALTER TABLE profiles +ADD COLUMN IF NOT EXISTS identity_key TEXT, +ADD COLUMN IF NOT EXISTS registration_id INTEGER; + +-- ============================================================================ +-- 2. Create separate one_time_prekeys table +-- ============================================================================ + +-- Separate table for one-time pre-keys (consumed on use) +CREATE TABLE IF NOT EXISTS one_time_prekeys ( + id SERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + key_id INTEGER NOT NULL, + public_key TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + + -- Ensure unique key_id per user + UNIQUE(user_id, key_id) +); + +-- Index for efficient key consumption +CREATE INDEX IF NOT EXISTS idx_one_time_prekeys_user_id ON one_time_prekeys(user_id); + +-- ============================================================================ +-- 3. Update signal_keys table structure +-- ============================================================================ + +-- Remove one_time_prekeys from signal_keys (now separate table) +ALTER TABLE signal_keys +DROP COLUMN IF EXISTS one_time_prekeys; + +-- Add registration_id to signal_keys if not already present +ALTER TABLE signal_keys +ADD COLUMN IF NOT EXISTS registration_id INTEGER; + +-- ============================================================================ +-- 4. Update consume_one_time_prekey function +-- ============================================================================ + +-- Update the function to work with the separate table +CREATE OR REPLACE FUNCTION consume_one_time_prekey(target_user_id UUID) +RETURNS TABLE(key_id INTEGER, public_key TEXT) AS $$ +BEGIN + RETURN QUERY + DELETE FROM one_time_prekeys + WHERE user_id = target_user_id + ORDER BY created_at ASC + LIMIT 1 + RETURNING one_time_prekeys.key_id, one_time_prekeys.public_key; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- ============================================================================ +-- 5. Update RLS policies for one_time_prekeys +-- ============================================================================ + +-- Enable RLS +ALTER TABLE one_time_prekeys ENABLE ROW LEVEL SECURITY; + +-- Users can read their own pre-keys (for management) +CREATE POLICY one_time_prekeys_select_own ON one_time_prekeys + FOR SELECT USING (auth.uid() = user_id); + +-- Users can insert their own pre-keys +CREATE POLICY one_time_prekeys_insert_own ON one_time_prekeys + FOR INSERT WITH CHECK (auth.uid() = user_id); + +-- Users can delete their own pre-keys (when consumed) +CREATE POLICY one_time_prekeys_delete_own ON one_time_prekeys + FOR DELETE USING (auth.uid() = user_id); + +-- ============================================================================ +-- 6. Migration helper: Move existing one_time_prekeys to new table +-- ============================================================================ + +-- Insert existing one_time_prekeys from signal_keys into the new table +INSERT INTO one_time_prekeys (user_id, key_id, public_key) +SELECT + sk.user_id, + (prekey->>'id')::INTEGER, + prekey->>'key' +FROM signal_keys sk, + jsonb_array_elements(sk.one_time_prekeys) AS prekey +ON CONFLICT (user_id, key_id) DO NOTHING; + +-- Remove the old column after migration +-- (Commented out to prevent accidental data loss - run manually after verification) +-- ALTER TABLE signal_keys DROP COLUMN IF EXISTS one_time_prekeys; + +-- ============================================================================ +-- 7. Comments for documentation +-- ============================================================================ + +COMMENT ON TABLE one_time_prekeys IS 'One-time pre-keys for Signal Protocol. Each key is consumed after first use.'; +COMMENT ON COLUMN profiles.identity_key IS 'Signal Protocol identity key public part (base64 encoded)'; +COMMENT ON COLUMN profiles.registration_id IS 'Signal Protocol registration ID for this user'; +COMMENT ON FUNCTION consume_one_time_prekey IS 'Atomically consumes and returns the oldest one-time pre-key for a user'; diff --git a/_legacy/supabase/migrations/20260119_e2ee_fix_policies.sql b/_legacy/supabase/migrations/20260119_e2ee_fix_policies.sql new file mode 100644 index 0000000..0217628 --- /dev/null +++ b/_legacy/supabase/migrations/20260119_e2ee_fix_policies.sql @@ -0,0 +1,111 @@ +-- ============================================================================ +-- E2EE Policy Fix Migration +-- ============================================================================ +-- This migration safely recreates policies that may have failed on initial run. +-- Uses DROP IF EXISTS before CREATE to be idempotent. +-- ============================================================================ + +-- ============================================================================ +-- 1. Fix e2ee_session_commands policies +-- ============================================================================ + +DROP POLICY IF EXISTS session_commands_select_own ON e2ee_session_commands; +DROP POLICY IF EXISTS session_commands_insert_own ON e2ee_session_commands; +DROP POLICY IF EXISTS session_commands_update_own ON e2ee_session_commands; + +CREATE POLICY session_commands_select_own ON e2ee_session_commands + FOR SELECT USING (auth.uid() = user_id); + +CREATE POLICY session_commands_insert_own ON e2ee_session_commands + FOR INSERT WITH CHECK (auth.uid() = user_id); + +CREATE POLICY session_commands_update_own ON e2ee_session_commands + FOR UPDATE USING (auth.uid() = user_id); + +-- ============================================================================ +-- 2. Fix e2ee_session_events policies +-- ============================================================================ + +DROP POLICY IF EXISTS session_events_select_own ON e2ee_session_events; +DROP POLICY IF EXISTS session_events_insert_own ON e2ee_session_events; +DROP POLICY IF EXISTS session_events_update_own ON e2ee_session_events; + +CREATE POLICY session_events_select_own ON e2ee_session_events + FOR SELECT USING (auth.uid() = user_id); + +CREATE POLICY session_events_insert_own ON e2ee_session_events + FOR INSERT WITH CHECK (auth.uid() = user_id); + +CREATE POLICY session_events_update_own ON e2ee_session_events + FOR UPDATE USING (auth.uid() = user_id); + +-- ============================================================================ +-- 3. Fix e2ee_decryption_failures policies +-- ============================================================================ + +DROP POLICY IF EXISTS decryption_failures_select_own ON e2ee_decryption_failures; +DROP POLICY IF EXISTS decryption_failures_insert_own ON e2ee_decryption_failures; +DROP POLICY IF EXISTS decryption_failures_update_own ON e2ee_decryption_failures; + +CREATE POLICY decryption_failures_select_own ON e2ee_decryption_failures + FOR SELECT USING (auth.uid() = recipient_id); + +CREATE POLICY decryption_failures_insert_own ON e2ee_decryption_failures + FOR INSERT WITH CHECK (auth.uid() = recipient_id); + +CREATE POLICY decryption_failures_update_own ON e2ee_decryption_failures + FOR UPDATE USING (auth.uid() = recipient_id); + +-- ============================================================================ +-- 4. Fix e2ee_session_state policies +-- ============================================================================ + +DROP POLICY IF EXISTS session_state_select_own ON e2ee_session_state; + +CREATE POLICY session_state_select_own ON e2ee_session_state + FOR SELECT USING (auth.uid() = user_id OR auth.uid() = peer_id); + +-- ============================================================================ +-- 5. Safely add tables to realtime publication (ignore if already added) +-- ============================================================================ + +DO $$ +BEGIN + -- Add e2ee_session_events if not already in publication + IF NOT EXISTS ( + SELECT 1 FROM pg_publication_tables + WHERE pubname = 'supabase_realtime' + AND tablename = 'e2ee_session_events' + ) THEN + ALTER PUBLICATION supabase_realtime ADD TABLE e2ee_session_events; + END IF; + + -- Add e2ee_session_commands if not already in publication + IF NOT EXISTS ( + SELECT 1 FROM pg_publication_tables + WHERE pubname = 'supabase_realtime' + AND tablename = 'e2ee_session_commands' + ) THEN + ALTER PUBLICATION supabase_realtime ADD TABLE e2ee_session_commands; + END IF; + + -- Add e2ee_session_state if not already in publication + IF NOT EXISTS ( + SELECT 1 FROM pg_publication_tables + WHERE pubname = 'supabase_realtime' + AND tablename = 'e2ee_session_state' + ) THEN + ALTER PUBLICATION supabase_realtime ADD TABLE e2ee_session_state; + END IF; +END $$; + +-- ============================================================================ +-- 6. Ensure event type constraint includes all types +-- ============================================================================ + +ALTER TABLE e2ee_session_events + DROP CONSTRAINT IF EXISTS e2ee_session_events_event_type_check; + +ALTER TABLE e2ee_session_events + ADD CONSTRAINT e2ee_session_events_event_type_check + CHECK (event_type IN ('session_reset', 'conversation_cleanup', 'key_refresh', 'decryption_failure', 'session_mismatch', 'session_established')); diff --git a/_legacy/supabase/migrations/20260119_e2ee_session_manager_tables.sql b/_legacy/supabase/migrations/20260119_e2ee_session_manager_tables.sql new file mode 100644 index 0000000..ab27295 --- /dev/null +++ b/_legacy/supabase/migrations/20260119_e2ee_session_manager_tables.sql @@ -0,0 +1,232 @@ +-- ============================================================================ +-- E2EE Session Manager Tables +-- ============================================================================ +-- Tables to support the E2EE session management edge function +-- These tables handle session commands, events, and cleanup operations +-- ============================================================================ + +-- ============================================================================ +-- 1. Session Commands Table +-- ============================================================================ + +-- Table to store session management commands +CREATE TABLE IF NOT EXISTS e2ee_session_commands ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Command details + user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + recipient_id UUID REFERENCES profiles(id) ON DELETE CASCADE, + conversation_id UUID REFERENCES encrypted_conversations(id) ON DELETE CASCADE, + command_type TEXT NOT NULL CHECK (command_type IN ('session_reset', 'conversation_cleanup', 'key_refresh')), + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'completed', 'failed')), + + -- Metadata + created_at TIMESTAMPTZ DEFAULT NOW(), + completed_at TIMESTAMPTZ, + error_message TEXT +); + +-- Indexes for efficient command processing +CREATE INDEX IF NOT EXISTS idx_session_commands_user ON e2ee_session_commands(user_id); +CREATE INDEX IF NOT EXISTS idx_session_commands_status ON e2ee_session_commands(status); +CREATE INDEX IF NOT EXISTS idx_session_commands_type ON e2ee_session_commands(command_type); +CREATE INDEX IF NOT EXISTS idx_session_commands_conversation ON e2ee_session_commands(conversation_id); + +-- ============================================================================ +-- 2. Session Events Table +-- ============================================================================ + +-- Table to store session management events for realtime notifications +CREATE TABLE IF NOT EXISTS e2ee_session_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Event details + user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + event_type TEXT NOT NULL CHECK (event_type IN ('session_reset', 'conversation_cleanup', 'key_refresh', 'decryption_failure')), + recipient_id UUID REFERENCES profiles(id) ON DELETE CASCADE, + conversation_id UUID REFERENCES encrypted_conversations(id) ON DELETE CASCADE, + + -- Additional context + message_id UUID REFERENCES encrypted_messages(id) ON DELETE CASCADE, + error_details JSONB, + + -- Metadata + timestamp TIMESTAMPTZ DEFAULT NOW(), + processed_at TIMESTAMPTZ, + processed_by TEXT +); + +-- Indexes for efficient event processing +CREATE INDEX IF NOT EXISTS idx_session_events_user ON e2ee_session_events(user_id); +CREATE INDEX IF NOT EXISTS idx_session_events_type ON e2ee_session_events(event_type); +CREATE INDEX IF NOT EXISTS idx_session_events_timestamp ON e2ee_session_events(timestamp DESC); + +-- ============================================================================ +-- 3. Decryption Failure Logs +-- ============================================================================ + +-- Table to log decryption failures for debugging and recovery +CREATE TABLE IF NOT EXISTS e2ee_decryption_failures ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Failure details + message_id UUID NOT NULL REFERENCES encrypted_messages(id) ON DELETE CASCADE, + sender_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + recipient_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + conversation_id UUID NOT NULL REFERENCES encrypted_conversations(id) ON DELETE CASCADE, + + -- Error information + error_type TEXT NOT NULL, + error_message TEXT, + stack_trace TEXT, + ciphertext_length INTEGER, + message_type INTEGER, + + -- Context for debugging + session_key_exists BOOLEAN, + ephemeral_key_in_header BOOLEAN, + header_data JSONB, + + -- Recovery status + recovery_attempted BOOLEAN DEFAULT FALSE, + recovery_success BOOLEAN, + recovery_method TEXT, + + -- Metadata + created_at TIMESTAMPTZ DEFAULT NOW(), + last_updated TIMESTAMPTZ DEFAULT NOW() +); + +-- Indexes for failure analysis +CREATE INDEX IF NOT EXISTS idx_decryption_failures_message ON e2ee_decryption_failures(message_id); +CREATE INDEX IF NOT EXISTS idx_decryption_failures_conversation ON e2ee_decryption_failures(conversation_id); +CREATE INDEX IF NOT EXISTS idx_decryption_failures_recipient ON e2ee_decryption_failures(recipient_id); +CREATE INDEX IF NOT EXISTS idx_decryption_failures_timestamp ON e2ee_decryption_failures(created_at DESC); + +-- ============================================================================ +-- 4. RLS Policies +-- ============================================================================ + +-- Enable RLS on all new tables +ALTER TABLE e2ee_session_commands ENABLE ROW LEVEL SECURITY; +ALTER TABLE e2ee_session_events ENABLE ROW LEVEL SECURITY; +ALTER TABLE e2ee_decryption_failures ENABLE ROW LEVEL SECURITY; + +-- Session Commands: Users can only see their own commands +CREATE POLICY session_commands_select_own ON e2ee_session_commands + FOR SELECT USING (auth.uid() = user_id); + +CREATE POLICY session_commands_insert_own ON e2ee_session_commands + FOR INSERT WITH CHECK (auth.uid() = user_id); + +CREATE POLICY session_commands_update_own ON e2ee_session_commands + FOR UPDATE USING (auth.uid() = user_id); + +-- Session Events: Users can only see their own events +CREATE POLICY session_events_select_own ON e2ee_session_events + FOR SELECT USING (auth.uid() = user_id); + +CREATE POLICY session_events_insert_own ON e2ee_session_events + FOR INSERT WITH CHECK (auth.uid() = user_id); + +CREATE POLICY session_events_update_own ON e2ee_session_events + FOR UPDATE USING (auth.uid() = user_id); + +-- Decryption Failures: Users can only see failures for their own messages +CREATE POLICY decryption_failures_select_own ON e2ee_decryption_failures + FOR SELECT USING (auth.uid() = recipient_id); + +CREATE POLICY decryption_failures_insert_own ON e2ee_decryption_failures + FOR INSERT WITH CHECK (auth.uid() = recipient_id); + +CREATE POLICY decryption_failures_update_own ON e2ee_decryption_failures + FOR UPDATE USING (auth.uid() = recipient_id); + +-- ============================================================================ +-- 5. Realtime Subscriptions +-- ============================================================================ + +-- Enable realtime for session events +ALTER PUBLICATION supabase_realtime ADD TABLE e2ee_session_events; +ALTER PUBLICATION supabase_realtime ADD TABLE e2ee_session_commands; + +-- ============================================================================ +-- 6. Comments for Documentation +-- ============================================================================ + +COMMENT ON TABLE e2ee_session_commands IS 'Commands for E2EE session management (reset, cleanup, key refresh). Processed by client and edge functions.'; +COMMENT ON TABLE e2ee_session_events IS 'Realtime events for E2EE session management. Triggers client-side actions.'; +COMMENT ON TABLE e2ee_decryption_failures IS 'Logs of decryption failures for debugging and automatic recovery.'; +COMMENT ON COLUMN e2ee_decryption_failures.error_type IS 'Type of decryption error (e.g., "mac_failure", "session_mismatch")'; +COMMENT ON COLUMN e2ee_decryption_failures.recovery_method IS 'Method used for recovery (e.g., "session_reset", "key_refresh")'; + +-- ============================================================================ +-- 7. Helper Functions +-- ============================================================================ + +-- Function to log decryption failures +CREATE OR REPLACE FUNCTION log_decryption_failure( + p_message_id UUID, + p_sender_id UUID, + p_recipient_id UUID, + p_conversation_id UUID, + p_error_type TEXT, + p_error_message TEXT, + p_stack_trace TEXT, + p_ciphertext_length INTEGER, + p_message_type INTEGER, + p_session_key_exists BOOLEAN, + p_ephemeral_key_in_header BOOLEAN, + p_header_data JSONB +) RETURNS VOID AS $$ +BEGIN + INSERT INTO e2ee_decryption_failures ( + message_id, sender_id, recipient_id, conversation_id, + error_type, error_message, stack_trace, ciphertext_length, message_type, + session_key_exists, ephemeral_key_in_header, header_data + ) VALUES ( + p_message_id, p_sender_id, p_recipient_id, p_conversation_id, + p_error_type, p_error_message, p_stack_trace, p_ciphertext_length, p_message_type, + p_session_key_exists, p_ephemeral_key_in_header, p_header_data + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Function to mark recovery attempts +CREATE OR REPLACE FUNCTION mark_recovery_attempt( + p_failure_id UUID, + p_recovery_method TEXT, + p_success BOOLEAN +) RETURNS VOID AS $$ +BEGIN + UPDATE e2ee_decryption_failures + SET + recovery_attempted = TRUE, + recovery_success = p_success, + recovery_method = p_recovery_method, + last_updated = NOW() + WHERE id = p_failure_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- ============================================================================ +-- 8. Cleanup Function +-- ============================================================================ + +-- Function to cleanup old session data +CREATE OR REPLACE FUNCTION cleanup_old_e2ee_data() +RETURNS VOID AS $$ +BEGIN + -- Delete old commands (older than 30 days) + DELETE FROM e2ee_session_commands + WHERE created_at < NOW() - INTERVAL '30 days'; + + -- Delete old events (older than 7 days) + DELETE FROM e2ee_session_events + WHERE timestamp < NOW() - INTERVAL '7 days'; + + -- Delete old failure logs (older than 90 days) + DELETE FROM e2ee_decryption_failures + WHERE created_at < NOW() - INTERVAL '90 days'; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/_legacy/supabase/migrations/20260119_e2ee_session_state.sql b/_legacy/supabase/migrations/20260119_e2ee_session_state.sql new file mode 100644 index 0000000..19cbf82 --- /dev/null +++ b/_legacy/supabase/migrations/20260119_e2ee_session_state.sql @@ -0,0 +1,255 @@ +-- ============================================================================ +-- E2EE Session State Tracking +-- ============================================================================ +-- Server-side session state tracking to detect and recover from session +-- mismatches between parties. The server cannot see session keys, only +-- metadata about whether sessions exist. +-- ============================================================================ + +-- ============================================================================ +-- 1. Session State Table +-- ============================================================================ + +-- Track which users have established sessions with each other +-- This allows detection of asymmetric session states (one party has session, other doesn't) +CREATE TABLE IF NOT EXISTS e2ee_session_state ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Session participants (always stored with user_id < peer_id for consistency) + user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + peer_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + + -- Session state flags + user_has_session BOOLEAN NOT NULL DEFAULT FALSE, + peer_has_session BOOLEAN NOT NULL DEFAULT FALSE, + + -- Session metadata (no actual keys stored!) + user_session_created_at TIMESTAMPTZ, + peer_session_created_at TIMESTAMPTZ, + + -- Version tracking for conflict resolution + user_session_version INTEGER NOT NULL DEFAULT 0, + peer_session_version INTEGER NOT NULL DEFAULT 0, + + -- Timestamps + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + -- Ensure unique pair (user_id should always be < peer_id) + CONSTRAINT e2ee_session_state_pair_unique UNIQUE (user_id, peer_id), + CONSTRAINT e2ee_session_state_ordering CHECK (user_id < peer_id) +); + +-- Indexes for efficient lookups +CREATE INDEX IF NOT EXISTS idx_session_state_user ON e2ee_session_state(user_id); +CREATE INDEX IF NOT EXISTS idx_session_state_peer ON e2ee_session_state(peer_id); +CREATE INDEX IF NOT EXISTS idx_session_state_mismatch ON e2ee_session_state(user_has_session, peer_has_session) + WHERE user_has_session != peer_has_session; + +-- ============================================================================ +-- 2. RLS Policies +-- ============================================================================ + +ALTER TABLE e2ee_session_state ENABLE ROW LEVEL SECURITY; + +-- Users can see session state for their own sessions +CREATE POLICY session_state_select_own ON e2ee_session_state + FOR SELECT USING (auth.uid() = user_id OR auth.uid() = peer_id); + +-- Users can only insert/update their own side of the session +-- (handled via function to enforce ordering) + +-- ============================================================================ +-- 3. Helper Functions +-- ============================================================================ + +-- Function to update session state (handles ordering automatically) +CREATE OR REPLACE FUNCTION update_e2ee_session_state( + p_user_id UUID, + p_peer_id UUID, + p_has_session BOOLEAN +) RETURNS JSONB AS $$ +DECLARE + v_lower_id UUID; + v_higher_id UUID; + v_is_user_lower BOOLEAN; + v_result JSONB; + v_session_state RECORD; +BEGIN + -- Determine ordering (user_id < peer_id constraint) + IF p_user_id < p_peer_id THEN + v_lower_id := p_user_id; + v_higher_id := p_peer_id; + v_is_user_lower := TRUE; + ELSE + v_lower_id := p_peer_id; + v_higher_id := p_user_id; + v_is_user_lower := FALSE; + END IF; + + -- Insert or update + INSERT INTO e2ee_session_state (user_id, peer_id, user_has_session, peer_has_session, user_session_created_at, peer_session_created_at, user_session_version, peer_session_version) + VALUES ( + v_lower_id, + v_higher_id, + CASE WHEN v_is_user_lower THEN p_has_session ELSE FALSE END, + CASE WHEN NOT v_is_user_lower THEN p_has_session ELSE FALSE END, + CASE WHEN v_is_user_lower AND p_has_session THEN NOW() ELSE NULL END, + CASE WHEN NOT v_is_user_lower AND p_has_session THEN NOW() ELSE NULL END, + CASE WHEN v_is_user_lower THEN 1 ELSE 0 END, + CASE WHEN NOT v_is_user_lower THEN 1 ELSE 0 END + ) + ON CONFLICT (user_id, peer_id) DO UPDATE SET + user_has_session = CASE + WHEN v_is_user_lower THEN p_has_session + ELSE e2ee_session_state.user_has_session + END, + peer_has_session = CASE + WHEN NOT v_is_user_lower THEN p_has_session + ELSE e2ee_session_state.peer_has_session + END, + user_session_created_at = CASE + WHEN v_is_user_lower AND p_has_session AND e2ee_session_state.user_session_created_at IS NULL THEN NOW() + WHEN v_is_user_lower AND NOT p_has_session THEN NULL + ELSE e2ee_session_state.user_session_created_at + END, + peer_session_created_at = CASE + WHEN NOT v_is_user_lower AND p_has_session AND e2ee_session_state.peer_session_created_at IS NULL THEN NOW() + WHEN NOT v_is_user_lower AND NOT p_has_session THEN NULL + ELSE e2ee_session_state.peer_session_created_at + END, + user_session_version = CASE + WHEN v_is_user_lower THEN e2ee_session_state.user_session_version + 1 + ELSE e2ee_session_state.user_session_version + END, + peer_session_version = CASE + WHEN NOT v_is_user_lower THEN e2ee_session_state.peer_session_version + 1 + ELSE e2ee_session_state.peer_session_version + END, + updated_at = NOW() + RETURNING * INTO v_session_state; + + -- Build result with mismatch detection + v_result := jsonb_build_object( + 'success', TRUE, + 'user_has_session', CASE WHEN v_is_user_lower THEN v_session_state.user_has_session ELSE v_session_state.peer_has_session END, + 'peer_has_session', CASE WHEN v_is_user_lower THEN v_session_state.peer_has_session ELSE v_session_state.user_has_session END, + 'session_mismatch', v_session_state.user_has_session != v_session_state.peer_has_session, + 'peer_session_version', CASE WHEN v_is_user_lower THEN v_session_state.peer_session_version ELSE v_session_state.user_session_version END + ); + + RETURN v_result; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Function to get session state between two users +CREATE OR REPLACE FUNCTION get_e2ee_session_state( + p_user_id UUID, + p_peer_id UUID +) RETURNS JSONB AS $$ +DECLARE + v_lower_id UUID; + v_higher_id UUID; + v_is_user_lower BOOLEAN; + v_session_state RECORD; +BEGIN + -- Determine ordering + IF p_user_id < p_peer_id THEN + v_lower_id := p_user_id; + v_higher_id := p_peer_id; + v_is_user_lower := TRUE; + ELSE + v_lower_id := p_peer_id; + v_higher_id := p_user_id; + v_is_user_lower := FALSE; + END IF; + + SELECT * INTO v_session_state + FROM e2ee_session_state + WHERE user_id = v_lower_id AND peer_id = v_higher_id; + + IF NOT FOUND THEN + RETURN jsonb_build_object( + 'exists', FALSE, + 'user_has_session', FALSE, + 'peer_has_session', FALSE, + 'session_mismatch', FALSE + ); + END IF; + + RETURN jsonb_build_object( + 'exists', TRUE, + 'user_has_session', CASE WHEN v_is_user_lower THEN v_session_state.user_has_session ELSE v_session_state.peer_has_session END, + 'peer_has_session', CASE WHEN v_is_user_lower THEN v_session_state.peer_has_session ELSE v_session_state.user_has_session END, + 'session_mismatch', v_session_state.user_has_session != v_session_state.peer_has_session, + 'user_session_version', CASE WHEN v_is_user_lower THEN v_session_state.user_session_version ELSE v_session_state.peer_session_version END, + 'peer_session_version', CASE WHEN v_is_user_lower THEN v_session_state.peer_session_version ELSE v_session_state.user_session_version END + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Function to clear session state (when resetting) +CREATE OR REPLACE FUNCTION clear_e2ee_session_state( + p_user_id UUID, + p_peer_id UUID +) RETURNS JSONB AS $$ +DECLARE + v_lower_id UUID; + v_higher_id UUID; + v_is_user_lower BOOLEAN; +BEGIN + -- Determine ordering + IF p_user_id < p_peer_id THEN + v_lower_id := p_user_id; + v_higher_id := p_peer_id; + v_is_user_lower := TRUE; + ELSE + v_lower_id := p_peer_id; + v_higher_id := p_user_id; + v_is_user_lower := FALSE; + END IF; + + -- Update only the caller's side of the session + UPDATE e2ee_session_state SET + user_has_session = CASE WHEN v_is_user_lower THEN FALSE ELSE user_has_session END, + peer_has_session = CASE WHEN NOT v_is_user_lower THEN FALSE ELSE peer_has_session END, + user_session_created_at = CASE WHEN v_is_user_lower THEN NULL ELSE user_session_created_at END, + peer_session_created_at = CASE WHEN NOT v_is_user_lower THEN NULL ELSE peer_session_created_at END, + user_session_version = CASE WHEN v_is_user_lower THEN user_session_version + 1 ELSE user_session_version END, + peer_session_version = CASE WHEN NOT v_is_user_lower THEN peer_session_version + 1 ELSE peer_session_version END, + updated_at = NOW() + WHERE user_id = v_lower_id AND peer_id = v_higher_id; + + RETURN jsonb_build_object('success', TRUE); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- ============================================================================ +-- 4. Add decryption_failure event type if not exists +-- ============================================================================ + +-- Update the check constraint to include session_mismatch event type +ALTER TABLE e2ee_session_events + DROP CONSTRAINT IF EXISTS e2ee_session_events_event_type_check; + +ALTER TABLE e2ee_session_events + ADD CONSTRAINT e2ee_session_events_event_type_check + CHECK (event_type IN ('session_reset', 'conversation_cleanup', 'key_refresh', 'decryption_failure', 'session_mismatch', 'session_established')); + +-- ============================================================================ +-- 5. Realtime for session state changes +-- ============================================================================ + +ALTER PUBLICATION supabase_realtime ADD TABLE e2ee_session_state; + +-- ============================================================================ +-- 6. Comments +-- ============================================================================ + +COMMENT ON TABLE e2ee_session_state IS 'Server-side tracking of E2EE session existence between user pairs. Does NOT store actual keys.'; +COMMENT ON COLUMN e2ee_session_state.user_has_session IS 'Whether the user with smaller UUID has an active session'; +COMMENT ON COLUMN e2ee_session_state.peer_has_session IS 'Whether the user with larger UUID has an active session'; +COMMENT ON COLUMN e2ee_session_state.user_session_version IS 'Incremented each time user updates their session state'; +COMMENT ON FUNCTION update_e2ee_session_state IS 'Update session state for a user-peer pair. Handles UUID ordering automatically.'; +COMMENT ON FUNCTION get_e2ee_session_state IS 'Get session state between two users. Returns mismatch detection.'; +COMMENT ON FUNCTION clear_e2ee_session_state IS 'Clear session state for a user (used during session reset).'; diff --git a/_legacy/supabase/migrations/20260120_fix_user_post_access.sql b/_legacy/supabase/migrations/20260120_fix_user_post_access.sql new file mode 100644 index 0000000..50e0d0c --- /dev/null +++ b/_legacy/supabase/migrations/20260120_fix_user_post_access.sql @@ -0,0 +1,135 @@ +-- Fix for recurring post/appreciate access issues for specific users +-- This migration ensures: +-- 1. All posts have valid visibility values +-- 2. Profile privacy settings are consistent +-- 3. RLS policies don't conflict +-- 4. Users can always see public posts regardless of their own privacy settings + +-- Step 1: Ensure all posts have visibility set (fix any nulls) +UPDATE posts +SET visibility = 'public' +WHERE visibility IS NULL; + +-- Step 2: Ensure profiles have consistent privacy settings +UPDATE profiles +SET is_private = false +WHERE is_private IS NULL; + +-- Step 2b: Official accounts should always be public (is_private = false) +-- This is because official/verified accounts are meant to be publicly accessible +UPDATE profiles +SET is_private = false +WHERE is_official = true AND is_private = true; + +-- Step 3: Drop ALL conflicting policies and create a unified one +DROP POLICY IF EXISTS posts_select_private_model ON posts; +DROP POLICY IF EXISTS posts_select_policy ON posts; +DROP POLICY IF EXISTS posts_visibility_policy ON posts; +DROP POLICY IF EXISTS posts_select_unified ON posts; + +-- Unified SELECT policy that combines both models: +-- ANY authenticated user can see post if: +-- a) They are the author (always see own posts) +-- b) Post visibility is 'public' (ANYONE can see public posts) +-- c) Post visibility is 'followers' AND user has accepted follow to author +-- d) Author's profile is NOT private (legacy backward compat - treat as public) +-- +-- IMPORTANT: The viewer's own is_private setting does NOT affect what they can see. +-- is_private only affects whether OTHERS can see YOUR posts without following you. +CREATE POLICY posts_select_unified ON posts + FOR SELECT + USING ( + -- Author can always see their own posts + auth.uid() = author_id + -- Public posts visible to ALL authenticated users + OR visibility = 'public' + -- Followers-only posts visible to accepted followers + OR ( + visibility = 'followers' + AND EXISTS ( + SELECT 1 + FROM follows f + WHERE f.follower_id = auth.uid() + AND f.following_id = posts.author_id + AND f.status = 'accepted' + ) + ) + -- Legacy: If author's profile is NOT private, treat their non-private posts as visible + -- This handles posts created before visibility column existed + OR ( + visibility IS DISTINCT FROM 'private' + AND EXISTS ( + SELECT 1 + FROM profiles p + WHERE p.id = posts.author_id + AND p.is_private = false + ) + ) + ); + +-- Step 4: Ensure INSERT policy exists for posts +DROP POLICY IF EXISTS posts_insert_policy ON posts; +CREATE POLICY posts_insert_policy ON posts + FOR INSERT + WITH CHECK (auth.uid() = author_id); + +-- Step 5: Ensure UPDATE policy exists for posts +DROP POLICY IF EXISTS posts_update_policy ON posts; +CREATE POLICY posts_update_policy ON posts + FOR UPDATE + USING (auth.uid() = author_id) + WITH CHECK (auth.uid() = author_id); + +-- Step 6: Ensure DELETE policy exists for posts +DROP POLICY IF EXISTS posts_delete_policy ON posts; +CREATE POLICY posts_delete_policy ON posts + FOR DELETE + USING (auth.uid() = author_id); + +-- Step 7: Fix post_likes table RLS +ALTER TABLE IF EXISTS post_likes ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS post_likes_select_policy ON post_likes; +CREATE POLICY post_likes_select_policy ON post_likes + FOR SELECT + USING (true); -- Anyone can see likes (needed for like counts) + +DROP POLICY IF EXISTS post_likes_insert_policy ON post_likes; +CREATE POLICY post_likes_insert_policy ON post_likes + FOR INSERT + WITH CHECK (auth.uid() = user_id); + +DROP POLICY IF EXISTS post_likes_delete_policy ON post_likes; +CREATE POLICY post_likes_delete_policy ON post_likes + FOR DELETE + USING (auth.uid() = user_id); + +-- Step 8: Create index for faster RLS checks +CREATE INDEX IF NOT EXISTS idx_follows_follower_following_status + ON follows(follower_id, following_id, status); + +CREATE INDEX IF NOT EXISTS idx_posts_author_visibility + ON posts(author_id, visibility); + +-- Step 7b: Fix post_saves table RLS (same pattern as post_likes) +ALTER TABLE IF EXISTS post_saves ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS post_saves_select_policy ON post_saves; +CREATE POLICY post_saves_select_policy ON post_saves + FOR SELECT + USING (auth.uid() = user_id); -- Users can only see their own saves + +DROP POLICY IF EXISTS post_saves_insert_policy ON post_saves; +CREATE POLICY post_saves_insert_policy ON post_saves + FOR INSERT + WITH CHECK (auth.uid() = user_id); + +DROP POLICY IF EXISTS post_saves_delete_policy ON post_saves; +CREATE POLICY post_saves_delete_policy ON post_saves + FOR DELETE + USING (auth.uid() = user_id); + +-- Step 9: Grant necessary permissions +GRANT SELECT, INSERT, UPDATE, DELETE ON posts TO authenticated; +GRANT SELECT, INSERT, DELETE ON post_likes TO authenticated; +GRANT SELECT, INSERT, DELETE ON post_saves TO authenticated; diff --git a/_legacy/supabase/migrations/20260120_notification_archive.sql b/_legacy/supabase/migrations/20260120_notification_archive.sql new file mode 100644 index 0000000..4fd0475 --- /dev/null +++ b/_legacy/supabase/migrations/20260120_notification_archive.sql @@ -0,0 +1,24 @@ +-- Add archiving for notifications +alter table notifications + add column if not exists archived_at timestamptz; + +create index if not exists idx_notifications_user_archived + on notifications (user_id, archived_at, created_at desc); + +-- Update unread count to ignore archived notifications +create or replace function get_unread_notification_count(p_user_id uuid) +returns integer +language plpgsql +stable +security definer +as $$ +begin + return ( + select count(*)::integer + from notifications + where user_id = p_user_id + and is_read = false + and archived_at is null + ); +end; +$$; diff --git a/_legacy/supabase/migrations/20260120_notification_rpc.sql b/_legacy/supabase/migrations/20260120_notification_rpc.sql new file mode 100644 index 0000000..d6aee39 --- /dev/null +++ b/_legacy/supabase/migrations/20260120_notification_rpc.sql @@ -0,0 +1,24 @@ +-- ============================================================================ +-- NOTIFICATION RPC FUNCTIONS +-- Helper functions for notification queries +-- ============================================================================ + +-- Get unread notification count for a user +create or replace function get_unread_notification_count(p_user_id uuid) +returns integer +language plpgsql +stable +security definer +as $$ +begin + return ( + select count(*)::integer + from notifications + where user_id = p_user_id + and is_read = false + ); +end; +$$; + +-- Grant execute permission to authenticated users +grant execute on function get_unread_notification_count(uuid) to authenticated; diff --git a/_legacy/supabase/migrations/20260120_notification_triggers.sql b/_legacy/supabase/migrations/20260120_notification_triggers.sql new file mode 100644 index 0000000..32f183e --- /dev/null +++ b/_legacy/supabase/migrations/20260120_notification_triggers.sql @@ -0,0 +1,294 @@ +-- ============================================================================ +-- NOTIFICATION TRIGGERS +-- Automatically create notifications for appreciates, comments, mentions, chains +-- ============================================================================ + +-- =========================================== +-- 1. APPRECIATE (LIKE) NOTIFICATIONS +-- =========================================== +create or replace function handle_appreciate_notification() +returns trigger +language plpgsql +security definer +as $$ +declare + post_author_id uuid; +begin + -- Get the post author + select author_id into post_author_id + from posts + where id = new.post_id; + + -- Don't notify if user likes their own post + if post_author_id is null or post_author_id = new.user_id then + return new; + end if; + + -- Insert notification + insert into notifications (user_id, type, actor_id, post_id, metadata) + values ( + post_author_id, + 'appreciate', + new.user_id, + new.post_id, + jsonb_build_object( + 'post_id', new.post_id, + 'liker_id', new.user_id + ) + ); + + return new; +end; +$$; + +drop trigger if exists appreciate_notification_trigger on post_likes; +create trigger appreciate_notification_trigger +after insert on post_likes +for each row execute function handle_appreciate_notification(); + + +-- =========================================== +-- 2. COMMENT NOTIFICATIONS +-- =========================================== +create or replace function handle_comment_notification() +returns trigger +language plpgsql +security definer +as $$ +declare + post_author_id uuid; +begin + -- Get the post author + select author_id into post_author_id + from posts + where id = new.post_id; + + -- Don't notify if user comments on their own post + if post_author_id is null or post_author_id = new.author_id then + return new; + end if; + + -- Insert notification + insert into notifications (user_id, type, actor_id, post_id, comment_id, metadata) + values ( + post_author_id, + 'comment', + new.author_id, + new.post_id, + new.id, + jsonb_build_object( + 'post_id', new.post_id, + 'comment_id', new.id, + 'comment_preview', left(new.body, 100) + ) + ); + + return new; +end; +$$; + +drop trigger if exists comment_notification_trigger on comments; +create trigger comment_notification_trigger +after insert on comments +for each row execute function handle_comment_notification(); + + +-- =========================================== +-- 3. MENTION NOTIFICATIONS +-- =========================================== +create or replace function handle_mention_notification() +returns trigger +language plpgsql +security definer +as $$ +declare + mentioned_handle text; + mentioned_user_id uuid; + mention_matches text[]; +begin + -- Extract @mentions from the body using regex + -- Matches @handle patterns (lowercase letters, numbers, underscores, 3-20 chars) + for mentioned_handle in + select (regexp_matches(new.body, '@([a-z0-9_]{3,20})', 'gi'))[1] + loop + -- Look up the mentioned user + select id into mentioned_user_id + from profiles + where lower(handle) = lower(mentioned_handle); + + -- Skip if user not found or mentioning self + if mentioned_user_id is null or mentioned_user_id = new.author_id then + continue; + end if; + + -- Insert mention notification (for comments) + insert into notifications (user_id, type, actor_id, post_id, comment_id, metadata) + values ( + mentioned_user_id, + 'mention', + new.author_id, + new.post_id, + new.id, + jsonb_build_object( + 'post_id', new.post_id, + 'comment_id', new.id, + 'mentioned_in', 'comment', + 'preview', left(new.body, 100) + ) + ) + on conflict do nothing; -- Avoid duplicate notifications + end loop; + + return new; +end; +$$; + +drop trigger if exists mention_notification_trigger on comments; +create trigger mention_notification_trigger +after insert on comments +for each row execute function handle_mention_notification(); + + +-- Also handle mentions in posts +create or replace function handle_post_mention_notification() +returns trigger +language plpgsql +security definer +as $$ +declare + mentioned_handle text; + mentioned_user_id uuid; +begin + -- Extract @mentions from the post body + for mentioned_handle in + select (regexp_matches(new.body, '@([a-z0-9_]{3,20})', 'gi'))[1] + loop + -- Look up the mentioned user + select id into mentioned_user_id + from profiles + where lower(handle) = lower(mentioned_handle); + + -- Skip if user not found or mentioning self + if mentioned_user_id is null or mentioned_user_id = new.author_id then + continue; + end if; + + -- Insert mention notification (for posts) + insert into notifications (user_id, type, actor_id, post_id, metadata) + values ( + mentioned_user_id, + 'mention', + new.author_id, + new.id, + jsonb_build_object( + 'post_id', new.id, + 'mentioned_in', 'post', + 'preview', left(new.body, 100) + ) + ) + on conflict do nothing; + end loop; + + return new; +end; +$$; + +drop trigger if exists post_mention_notification_trigger on posts; +create trigger post_mention_notification_trigger +after insert on posts +for each row execute function handle_post_mention_notification(); + + +-- =========================================== +-- 4. CHAIN (REPOST) NOTIFICATIONS +-- =========================================== +create or replace function handle_chain_notification() +returns trigger +language plpgsql +security definer +as $$ +declare + parent_author_id uuid; +begin + -- Only trigger if this is a chain (has parent) + if new.chain_parent_id is null then + return new; + end if; + + -- Get the parent post author + select author_id into parent_author_id + from posts + where id = new.chain_parent_id; + + -- Don't notify if user chains their own post + if parent_author_id is null or parent_author_id = new.author_id then + return new; + end if; + + -- Insert notification + insert into notifications (user_id, type, actor_id, post_id, metadata) + values ( + parent_author_id, + 'chain', + new.author_id, + new.chain_parent_id, -- Reference the original post + jsonb_build_object( + 'original_post_id', new.chain_parent_id, + 'chain_post_id', new.id, + 'chain_preview', left(new.body, 100) + ) + ); + + return new; +end; +$$; + +drop trigger if exists chain_notification_trigger on posts; +create trigger chain_notification_trigger +after insert on posts +for each row execute function handle_chain_notification(); + + +-- =========================================== +-- 5. ADD UNIQUE CONSTRAINT TO PREVENT DUPLICATES +-- =========================================== +-- Prevent duplicate notifications for the same action +-- (e.g., user can't appreciate same post twice anyway, but this adds safety) +create unique index if not exists idx_notifications_unique_appreciate +on notifications (user_id, type, actor_id, post_id) +where type = 'appreciate' and comment_id is null; + +create unique index if not exists idx_notifications_unique_comment +on notifications (user_id, type, actor_id, comment_id) +where type = 'comment' and comment_id is not null; + +create unique index if not exists idx_notifications_unique_chain +on notifications (user_id, type, actor_id, (metadata->>'chain_post_id')) +where type = 'chain'; + + +-- =========================================== +-- 6. ADD INDEX FOR FASTER NOTIFICATION QUERIES +-- =========================================== +create index if not exists idx_notifications_user_unread +on notifications (user_id, is_read, created_at desc) +where is_read = false; + +create index if not exists idx_notifications_user_created +on notifications (user_id, created_at desc); + + +-- =========================================== +-- 7. ENABLE REALTIME FOR NOTIFICATIONS +-- =========================================== +-- This allows clients to subscribe to notification changes +do $$ +begin + if not exists ( + select 1 from pg_publication_tables + where pubname = 'supabase_realtime' + and tablename = 'notifications' + ) then + alter publication supabase_realtime add table notifications; + end if; +end $$; diff --git a/_legacy/supabase/migrations/20260120_user_fcm_tokens.sql b/_legacy/supabase/migrations/20260120_user_fcm_tokens.sql new file mode 100644 index 0000000..1a6b5f3 --- /dev/null +++ b/_legacy/supabase/migrations/20260120_user_fcm_tokens.sql @@ -0,0 +1,62 @@ +-- ============================================================================ +-- User FCM Tokens for Push Notifications +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS user_fcm_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + device_type TEXT NOT NULL, + last_updated TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_user_fcm_tokens_user_id ON user_fcm_tokens(user_id); + +ALTER TABLE user_fcm_tokens ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS user_fcm_tokens_select ON user_fcm_tokens; +CREATE POLICY user_fcm_tokens_select ON user_fcm_tokens + FOR SELECT USING (auth.uid() = user_id); + +DROP POLICY IF EXISTS user_fcm_tokens_insert ON user_fcm_tokens; +CREATE POLICY user_fcm_tokens_insert ON user_fcm_tokens + FOR INSERT WITH CHECK (auth.uid() = user_id); + +DROP POLICY IF EXISTS user_fcm_tokens_update ON user_fcm_tokens; +CREATE POLICY user_fcm_tokens_update ON user_fcm_tokens + FOR UPDATE USING (auth.uid() = user_id); + +DROP POLICY IF EXISTS user_fcm_tokens_delete ON user_fcm_tokens; +CREATE POLICY user_fcm_tokens_delete ON user_fcm_tokens + FOR DELETE USING (auth.uid() = user_id); + +CREATE OR REPLACE FUNCTION set_user_fcm_tokens_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.last_updated = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_user_fcm_tokens_updated_at ON user_fcm_tokens; +CREATE TRIGGER trg_user_fcm_tokens_updated_at + BEFORE UPDATE ON user_fcm_tokens + FOR EACH ROW + EXECUTE FUNCTION set_user_fcm_tokens_updated_at(); + +-- Function to clean up stale FCM tokens (not updated in 30 days) +CREATE OR REPLACE FUNCTION cleanup_stale_fcm_tokens() +RETURNS INTEGER +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + deleted_count INTEGER; +BEGIN + DELETE FROM user_fcm_tokens + WHERE last_updated < NOW() - INTERVAL '30 days'; + + GET DIAGNOSTICS deleted_count = ROW_COUNT; + RETURN deleted_count; +END; +$$; diff --git a/_legacy/supabase/migrations/20260121_create_user_settings.sql b/_legacy/supabase/migrations/20260121_create_user_settings.sql new file mode 100644 index 0000000..a363443 --- /dev/null +++ b/_legacy/supabase/migrations/20260121_create_user_settings.sql @@ -0,0 +1,61 @@ +-- Create user_settings table (if missing) and backfill rows + +create table if not exists user_settings ( + user_id uuid primary key references profiles(id) on delete cascade, + default_post_ttl interval, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists idx_user_settings_user_id on user_settings(user_id); + +alter table user_settings enable row level security; + +drop policy if exists user_settings_select on user_settings; +create policy user_settings_select on user_settings + for select using (auth.uid() = user_id); + +drop policy if exists user_settings_insert on user_settings; +create policy user_settings_insert on user_settings + for insert with check (auth.uid() = user_id); + +drop policy if exists user_settings_update on user_settings; +create policy user_settings_update on user_settings + for update using (auth.uid() = user_id); + +drop policy if exists user_settings_delete on user_settings; +create policy user_settings_delete on user_settings + for delete using (auth.uid() = user_id); + +-- Backfill missing settings rows for existing users +insert into user_settings (user_id) +select p.id +from profiles p +where not exists ( + select 1 from user_settings us where us.user_id = p.id +); + +-- Ensure new users get settings row +create or replace function handle_new_user() +returns trigger +language plpgsql +security definer +as $$ +begin + insert into public.profiles (id, handle, display_name) + values ( + new.id, + coalesce(new.raw_user_meta_data->>'handle', new.email), + coalesce(new.raw_user_meta_data->>'display_name', new.email) + ); + + insert into public.trust_state (user_id, harmony_score, tier, posts_today) + values (new.id, 50, 'new', 0); + + insert into public.user_settings (user_id) + values (new.id) + on conflict (user_id) do nothing; + + return new; +end; +$$; diff --git a/_legacy/supabase/migrations/20260121_quips_support.sql b/_legacy/supabase/migrations/20260121_quips_support.sql new file mode 100644 index 0000000..c4edd29 --- /dev/null +++ b/_legacy/supabase/migrations/20260121_quips_support.sql @@ -0,0 +1,47 @@ +-- Quips support: video columns + storage bucket policies + +-- Posts table: ensure quip-friendly columns +alter table posts + add column if not exists type text not null default 'post', + add column if not exists video_url text, + add column if not exists thumbnail_url text, + add column if not exists duration_ms integer; + +create index if not exists idx_posts_type on posts (type); + +-- Storage bucket for quips (public read) +insert into storage.buckets (id, name, public) +values ('sojorn-videos', 'sojorn-videos', true) +on conflict (id) do update set public = excluded.public; + +-- Policies for quips bucket +do $$ +begin + if not exists ( + select 1 from pg_policies where policyname = 'Public read sojorn-videos' + ) then + create policy "Public read sojorn-videos" + on storage.objects + for select + using (bucket_id = 'sojorn-videos'); + end if; + + if not exists ( + select 1 from pg_policies where policyname = 'Authenticated upload sojorn-videos' + ) then + create policy "Authenticated upload sojorn-videos" + on storage.objects + for insert + with check (bucket_id = 'sojorn-videos' and auth.role() = 'authenticated'); + end if; + + if not exists ( + select 1 from pg_policies where policyname = 'Authenticated update sojorn-videos' + ) then + create policy "Authenticated update sojorn-videos" + on storage.objects + for update + using (bucket_id = 'sojorn-videos' and auth.role() = 'authenticated') + with check (bucket_id = 'sojorn-videos' and auth.role() = 'authenticated'); + end if; +end $$; diff --git a/_legacy/supabase/migrations/add_beacon_opt_in.sql b/_legacy/supabase/migrations/add_beacon_opt_in.sql new file mode 100644 index 0000000..452860a --- /dev/null +++ b/_legacy/supabase/migrations/add_beacon_opt_in.sql @@ -0,0 +1,11 @@ +-- Add beacon opt-in preference to profiles table +-- Users must explicitly opt-in to see beacon posts in their feeds + +-- Add beacon_enabled column (default FALSE = opted out) +ALTER TABLE profiles ADD COLUMN IF NOT EXISTS beacon_enabled BOOLEAN NOT NULL DEFAULT FALSE; + +-- Add index for faster beacon filtering queries +CREATE INDEX IF NOT EXISTS idx_profiles_beacon_enabled ON profiles(beacon_enabled) WHERE beacon_enabled = TRUE; + +-- Add comment to explain the column +COMMENT ON COLUMN profiles.beacon_enabled IS 'Whether user has opted into viewing Beacon Network posts in their feeds. Beacons are always visible on the Beacon map regardless of this setting.'; diff --git a/_legacy/supabase/migrations/create_searchable_tags_view.sql b/_legacy/supabase/migrations/create_searchable_tags_view.sql new file mode 100644 index 0000000..3bdb880 --- /dev/null +++ b/_legacy/supabase/migrations/create_searchable_tags_view.sql @@ -0,0 +1,21 @@ +-- Create a materialized view for efficient tag searching +-- This aggregates all tags from active posts and their counts +-- Used by the search Edge Function to avoid downloading all posts + +CREATE OR REPLACE VIEW view_searchable_tags AS +SELECT + unnest(tags) as tag, + COUNT(*) as count +FROM posts +WHERE + deleted_at IS NULL + AND tags IS NOT NULL + AND array_length(tags, 1) > 0 +GROUP BY unnest(tags) +ORDER BY count DESC; + +-- Create an index on the view for faster tag searches +-- Note: We can't create indexes on regular views, but we document this for potential future materialized view +-- If performance becomes an issue with large datasets, convert this to a MATERIALIZED VIEW and add: +-- CREATE INDEX idx_searchable_tags_tag ON view_searchable_tags(tag); +-- Then add a refresh strategy (e.g., REFRESH MATERIALIZED VIEW view_searchable_tags CONCURRENTLY) diff --git a/_legacy/supabase/migrations/create_sponsored_posts.sql b/_legacy/supabase/migrations/create_sponsored_posts.sql new file mode 100644 index 0000000..08c7b76 --- /dev/null +++ b/_legacy/supabase/migrations/create_sponsored_posts.sql @@ -0,0 +1,43 @@ +-- Migration: Create sponsored_posts table for First-Party Contextual Ads +-- Created: 2026-01-12 +-- Purpose: Silent launch infrastructure for sponsored content (table starts empty) + +create table if not exists sponsored_posts ( + id uuid default gen_random_uuid() primary key, + created_at timestamptz default now(), + advertiser_name text not null, + body text not null, -- Markdown content + image_url text, -- Optional banner + cta_link text not null, + cta_text text default 'Learn More', + target_categories text[] not null, -- Array of Category IDs to match against + active boolean default true, + + -- Internal tracking (private) + impression_goal int default 1000, + current_impressions int default 0 +); + +-- RLS: Public Read-Only (authenticated), Service Role Write-Only +alter table sponsored_posts enable row level security; + +create policy "Users can read active ads" on sponsored_posts + for select to authenticated using (active = true); + +-- Index for efficient category matching queries +create index if not exists idx_sponsored_posts_categories +on sponsored_posts using gin (target_categories); + +-- Index for active ads filtering +create index if not exists idx_sponsored_posts_active +on sponsored_posts (active) where active = true; + +-- Function to increment ad impressions (called from client) +create or replace function increment_ad_impression(p_ad_id uuid) +returns void as $$ +begin + update sponsored_posts + set current_impressions = current_impressions + 1 + where id = p_ad_id; +end; +$$ language plpgsql security definer; diff --git a/_legacy/supabase/migrations/fix_beacon_coordinates.sql b/_legacy/supabase/migrations/fix_beacon_coordinates.sql new file mode 100644 index 0000000..49d1698 --- /dev/null +++ b/_legacy/supabase/migrations/fix_beacon_coordinates.sql @@ -0,0 +1,90 @@ +-- Fix beacon coordinates: Add lat/long to fetch_beacons function +-- This replaces the existing function to include actual beacon coordinates + +CREATE OR REPLACE FUNCTION fetch_beacons( + lat DOUBLE PRECISION, + long DOUBLE PRECISION, + radius_meters DOUBLE PRECISION DEFAULT 5000, + beacon_type_filter TEXT DEFAULT NULL, + limit_count INTEGER DEFAULT 50 +) +RETURNS TABLE ( + id UUID, + body TEXT, + author_id UUID, + beacon_type TEXT, + confidence_score NUMERIC, + is_active_beacon BOOLEAN, + created_at TIMESTAMPTZ, + distance_meters DOUBLE PRECISION, + beacon_lat DOUBLE PRECISION, + beacon_long DOUBLE PRECISION, + author_handle TEXT, + author_display_name TEXT, + author_avatar_url TEXT, + vouch_count INTEGER, + report_count INTEGER, + status_color TEXT +) LANGUAGE plpgsql STABLE AS $$ +BEGIN + RETURN QUERY + SELECT + p.id, + p.body, + p.author_id, + p.beacon_type::TEXT, + p.confidence_score, + p.is_active_beacon, + p.created_at, + ST_Distance(p.location, ST_SetSRID(ST_MakePoint(long, lat), 4326)::geography) AS distance_meters, + ST_Y(p.location::geometry) AS beacon_lat, + ST_X(p.location::geometry) AS beacon_long, + prof.handle AS author_handle, + prof.display_name AS author_display_name, + prof.avatar_url AS author_avatar_url, + COALESCE(vouch.cnt, 0)::INT, + COALESCE(report.cnt, 0)::INT, + get_beacon_status_color(p.confidence_score) + FROM posts p + LEFT JOIN profiles prof ON p.author_id = prof.id + LEFT JOIN (SELECT beacon_id, COUNT(*)::INT AS cnt FROM beacon_votes WHERE vote_type = 'vouch' GROUP BY beacon_id) vouch ON p.id = vouch.beacon_id + LEFT JOIN (SELECT beacon_id, COUNT(*)::INT AS cnt FROM beacon_votes WHERE vote_type = 'report' GROUP BY beacon_id) report ON p.id = report.beacon_id + WHERE p.is_beacon = TRUE + AND p.is_active_beacon = TRUE + AND p.deleted_at IS NULL + AND (beacon_type_filter IS NULL OR p.beacon_type::TEXT = beacon_type_filter) + AND ST_DWithin(p.location, ST_SetSRID(ST_MakePoint(long, lat), 4326)::geography, radius_meters) + ORDER BY p.confidence_score DESC, p.created_at DESC + LIMIT limit_count; +END; +$$; + +-- Create alias function for backward compatibility with Dart service +CREATE OR REPLACE FUNCTION fetch_nearby_beacons( + p_lat DOUBLE PRECISION, + p_long DOUBLE PRECISION, + p_radius INTEGER DEFAULT 5000 +) +RETURNS TABLE ( + id UUID, + body TEXT, + author_id UUID, + beacon_type TEXT, + confidence_score NUMERIC, + is_active_beacon BOOLEAN, + created_at TIMESTAMPTZ, + distance_meters DOUBLE PRECISION, + beacon_lat DOUBLE PRECISION, + beacon_long DOUBLE PRECISION, + author_handle TEXT, + author_display_name TEXT, + author_avatar_url TEXT, + vouch_count INTEGER, + report_count INTEGER, + status_color TEXT +) LANGUAGE plpgsql STABLE AS $$ +BEGIN + RETURN QUERY + SELECT * FROM fetch_beacons(p_lat, p_long, p_radius::DOUBLE PRECISION, NULL, 50); +END; +$$; diff --git a/_legacy/supabase/migrations/unified_beacon_post_functions.sql b/_legacy/supabase/migrations/unified_beacon_post_functions.sql new file mode 100644 index 0000000..86cb7e7 --- /dev/null +++ b/_legacy/supabase/migrations/unified_beacon_post_functions.sql @@ -0,0 +1,296 @@ +-- Migration: Unified Beacon/Post RPC Functions +-- Updates RPC functions to return Post-compatible JSON structures + +-- ============================================================================ +-- get_beacon_details: Fetch a single beacon with full Post data structure +-- ============================================================================ +CREATE OR REPLACE FUNCTION get_beacon_details(p_beacon_id UUID) +RETURNS TABLE ( + id UUID, + body TEXT, + author_id UUID, + category_id UUID, + tone_label TEXT, + cis_score NUMERIC, + status TEXT, + created_at TIMESTAMPTZ, + edited_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + is_edited BOOLEAN, + allow_chain BOOLEAN, + chain_parent_id UUID, + image_url TEXT, + body_format TEXT, + background_id TEXT, + tags TEXT[], + is_beacon BOOLEAN, + beacon_type TEXT, + confidence_score NUMERIC, + is_active_beacon BOOLEAN, + latitude DOUBLE PRECISION, + longitude DOUBLE PRECISION, + distance_meters DOUBLE PRECISION, + author_handle TEXT, + author_display_name TEXT, + author_avatar_url TEXT, + vouch_count INTEGER, + report_count INTEGER, + user_vote TEXT, + status_color TEXT +) LANGUAGE plpgsql STABLE SECURITY DEFINER AS $$ +DECLARE + current_user_id UUID; +BEGIN + current_user_id := auth.uid(); + + RETURN QUERY + SELECT + p.id, + p.body, + p.author_id, + p.category_id, + p.tone_label::TEXT, + p.cis_score, + p.status::TEXT, + p.created_at, + p.edited_at, + p.deleted_at, + (p.edited_at IS NOT NULL) AS is_edited, + p.allow_chain, + p.chain_parent_id, + p.image_url, + p.body_format, + p.background_id, + p.tags, + p.is_beacon, + p.beacon_type::TEXT, + p.confidence_score, + p.is_active_beacon, + ST_Y(p.location::geometry) AS latitude, + ST_X(p.location::geometry) AS longitude, + 0.0 AS distance_meters, -- No distance calculation for single beacon fetch + prof.handle AS author_handle, + prof.display_name AS author_display_name, + prof.avatar_url AS author_avatar_url, + COALESCE(vouch.cnt, 0)::INT AS vouch_count, + COALESCE(report.cnt, 0)::INT AS report_count, + user_vote.vote_type AS user_vote, + get_beacon_status_color(p.confidence_score) AS status_color + FROM posts p + LEFT JOIN profiles prof ON p.author_id = prof.id + LEFT JOIN ( + SELECT beacon_id, COUNT(*)::INT AS cnt + FROM beacon_votes + WHERE vote_type = 'vouch' + GROUP BY beacon_id + ) vouch ON p.id = vouch.beacon_id + LEFT JOIN ( + SELECT beacon_id, COUNT(*)::INT AS cnt + FROM beacon_votes + WHERE vote_type = 'report' + GROUP BY beacon_id + ) report ON p.id = report.beacon_id + LEFT JOIN ( + SELECT beacon_id, vote_type + FROM beacon_votes + WHERE user_id = current_user_id + ) user_vote ON p.id = user_vote.beacon_id + WHERE p.id = p_beacon_id + AND p.is_beacon = TRUE + AND p.deleted_at IS NULL; +END; +$$; + +-- ============================================================================ +-- vouch_beacon: Add a vouch vote to a beacon +-- ============================================================================ +CREATE OR REPLACE FUNCTION vouch_beacon(p_beacon_id UUID) +RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$ +DECLARE + current_user_id UUID; +BEGIN + current_user_id := auth.uid(); + + IF current_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + -- Insert or update the vote + INSERT INTO beacon_votes (user_id, beacon_id, vote_type) + VALUES (current_user_id, p_beacon_id, 'vouch') + ON CONFLICT (user_id, beacon_id) + DO UPDATE SET vote_type = 'vouch', created_at = NOW(); +END; +$$; + +-- ============================================================================ +-- report_beacon: Add a report vote to a beacon +-- ============================================================================ +CREATE OR REPLACE FUNCTION report_beacon(p_beacon_id UUID) +RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$ +DECLARE + current_user_id UUID; +BEGIN + current_user_id := auth.uid(); + + IF current_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + -- Insert or update the vote + INSERT INTO beacon_votes (user_id, beacon_id, vote_type) + VALUES (current_user_id, p_beacon_id, 'report') + ON CONFLICT (user_id, beacon_id) + DO UPDATE SET vote_type = 'report', created_at = NOW(); + + -- Decrease confidence score + UPDATE posts + SET confidence_score = GREATEST(0.0, confidence_score - 0.1) + WHERE id = p_beacon_id AND is_beacon = TRUE; +END; +$$; + +-- ============================================================================ +-- remove_beacon_vote: Remove user's vote from a beacon +-- ============================================================================ +CREATE OR REPLACE FUNCTION remove_beacon_vote(p_beacon_id UUID) +RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$ +DECLARE + current_user_id UUID; +BEGIN + current_user_id := auth.uid(); + + IF current_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + DELETE FROM beacon_votes + WHERE user_id = current_user_id + AND beacon_id = p_beacon_id; +END; +$$; + +-- ============================================================================ +-- garbage_collect_beacons: Admin function to disable stale beacons +-- ============================================================================ +CREATE OR REPLACE FUNCTION garbage_collect_beacons() +RETURNS INTEGER LANGUAGE plpgsql SECURITY DEFINER AS $$ +DECLARE + disabled_count INTEGER; +BEGIN + -- Disable beacons that are: + -- 1. Older than 6 hours + -- 2. Have low confidence score (< 0.3) + -- 3. Are still marked as active + UPDATE posts + SET is_active_beacon = FALSE + WHERE is_beacon = TRUE + AND is_active_beacon = TRUE + AND confidence_score < 0.3 + AND created_at < NOW() - INTERVAL '6 hours' + AND deleted_at IS NULL; + + GET DIAGNOSTICS disabled_count = ROW_COUNT; + RETURN disabled_count; +END; +$$; + +-- ============================================================================ +-- Update fetch_nearby_beacons to return Post-compatible structure +-- ============================================================================ +CREATE OR REPLACE FUNCTION fetch_nearby_beacons( + p_lat DOUBLE PRECISION, + p_long DOUBLE PRECISION, + p_radius INTEGER DEFAULT 16000 +) +RETURNS TABLE ( + id UUID, + body TEXT, + author_id UUID, + category_id UUID, + tone_label TEXT, + cis_score NUMERIC, + status TEXT, + created_at TIMESTAMPTZ, + edited_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + is_edited BOOLEAN, + allow_chain BOOLEAN, + chain_parent_id UUID, + image_url TEXT, + body_format TEXT, + background_id TEXT, + tags TEXT[], + is_beacon BOOLEAN, + beacon_type TEXT, + confidence_score NUMERIC, + is_active_beacon BOOLEAN, + latitude DOUBLE PRECISION, + longitude DOUBLE PRECISION, + distance_meters DOUBLE PRECISION, + author_handle TEXT, + author_display_name TEXT, + author_avatar_url TEXT, + vouch_count INTEGER, + report_count INTEGER, + status_color TEXT +) LANGUAGE plpgsql STABLE AS $$ +BEGIN + RETURN QUERY + SELECT + p.id, + p.body, + p.author_id, + p.category_id, + p.tone_label::TEXT, + p.cis_score, + p.status::TEXT, + p.created_at, + p.edited_at, + p.deleted_at, + (p.edited_at IS NOT NULL) AS is_edited, + p.allow_chain, + p.chain_parent_id, + p.image_url, + p.body_format, + p.background_id, + p.tags, + p.is_beacon, + p.beacon_type::TEXT, + p.confidence_score, + p.is_active_beacon, + ST_Y(p.location::geometry) AS latitude, + ST_X(p.location::geometry) AS longitude, + ST_Distance(p.location, ST_SetSRID(ST_MakePoint(p_long, p_lat), 4326)::geography) AS distance_meters, + prof.handle AS author_handle, + prof.display_name AS author_display_name, + prof.avatar_url AS author_avatar_url, + COALESCE(vouch.cnt, 0)::INT AS vouch_count, + COALESCE(report.cnt, 0)::INT AS report_count, + get_beacon_status_color(p.confidence_score) AS status_color + FROM posts p + LEFT JOIN profiles prof ON p.author_id = prof.id + LEFT JOIN ( + SELECT beacon_id, COUNT(*)::INT AS cnt + FROM beacon_votes + WHERE vote_type = 'vouch' + GROUP BY beacon_id + ) vouch ON p.id = vouch.beacon_id + LEFT JOIN ( + SELECT beacon_id, COUNT(*)::INT AS cnt + FROM beacon_votes + WHERE vote_type = 'report' + GROUP BY beacon_id + ) report ON p.id = report.beacon_id + WHERE p.is_beacon = TRUE + AND p.is_active_beacon = TRUE + AND p.deleted_at IS NULL + AND ST_DWithin( + p.location, + ST_SetSRID(ST_MakePoint(p_long, p_lat), 4326)::geography, + p_radius::DOUBLE PRECISION + ) + ORDER BY p.confidence_score DESC, p.created_at DESC + LIMIT 100; +END; +$$; diff --git a/_legacy/supabase/migrations/unified_beacon_post_functions_v2.sql b/_legacy/supabase/migrations/unified_beacon_post_functions_v2.sql new file mode 100644 index 0000000..b5869ae --- /dev/null +++ b/_legacy/supabase/migrations/unified_beacon_post_functions_v2.sql @@ -0,0 +1,300 @@ +-- Migration: Unified Beacon/Post RPC Functions (v2 - with DROP statements) +-- Updates RPC functions to return Post-compatible JSON structures + +-- Drop existing functions first to avoid signature conflicts +DROP FUNCTION IF EXISTS get_beacon_details(UUID); +DROP FUNCTION IF EXISTS fetch_nearby_beacons(DOUBLE PRECISION, DOUBLE PRECISION, INTEGER); + +-- ============================================================================ +-- get_beacon_details: Fetch a single beacon with full Post data structure +-- ============================================================================ +CREATE OR REPLACE FUNCTION get_beacon_details(p_beacon_id UUID) +RETURNS TABLE ( + id UUID, + body TEXT, + author_id UUID, + category_id UUID, + tone_label TEXT, + cis_score NUMERIC, + status TEXT, + created_at TIMESTAMPTZ, + edited_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + is_edited BOOLEAN, + allow_chain BOOLEAN, + chain_parent_id UUID, + image_url TEXT, + body_format TEXT, + background_id TEXT, + tags TEXT[], + is_beacon BOOLEAN, + beacon_type TEXT, + confidence_score NUMERIC, + is_active_beacon BOOLEAN, + latitude DOUBLE PRECISION, + longitude DOUBLE PRECISION, + distance_meters DOUBLE PRECISION, + author_handle TEXT, + author_display_name TEXT, + author_avatar_url TEXT, + vouch_count INTEGER, + report_count INTEGER, + user_vote TEXT, + status_color TEXT +) LANGUAGE plpgsql STABLE SECURITY DEFINER AS $$ +DECLARE + current_user_id UUID; +BEGIN + current_user_id := auth.uid(); + + RETURN QUERY + SELECT + p.id, + p.body, + p.author_id, + p.category_id, + p.tone_label::TEXT, + p.cis_score, + p.status::TEXT, + p.created_at, + p.edited_at, + p.deleted_at, + (p.edited_at IS NOT NULL) AS is_edited, + p.allow_chain, + p.chain_parent_id, + p.image_url, + p.body_format, + p.background_id, + p.tags, + p.is_beacon, + p.beacon_type::TEXT, + p.confidence_score, + p.is_active_beacon, + ST_Y(p.location::geometry) AS latitude, + ST_X(p.location::geometry) AS longitude, + 0.0 AS distance_meters, -- No distance calculation for single beacon fetch + prof.handle AS author_handle, + prof.display_name AS author_display_name, + prof.avatar_url AS author_avatar_url, + COALESCE(vouch.cnt, 0)::INT AS vouch_count, + COALESCE(report.cnt, 0)::INT AS report_count, + user_vote.vote_type AS user_vote, + get_beacon_status_color(p.confidence_score) AS status_color + FROM posts p + LEFT JOIN profiles prof ON p.author_id = prof.id + LEFT JOIN ( + SELECT beacon_id, COUNT(*)::INT AS cnt + FROM beacon_votes + WHERE vote_type = 'vouch' + GROUP BY beacon_id + ) vouch ON p.id = vouch.beacon_id + LEFT JOIN ( + SELECT beacon_id, COUNT(*)::INT AS cnt + FROM beacon_votes + WHERE vote_type = 'report' + GROUP BY beacon_id + ) report ON p.id = report.beacon_id + LEFT JOIN ( + SELECT beacon_id, vote_type + FROM beacon_votes + WHERE user_id = current_user_id + ) user_vote ON p.id = user_vote.beacon_id + WHERE p.id = p_beacon_id + AND p.is_beacon = TRUE + AND p.deleted_at IS NULL; +END; +$$; + +-- ============================================================================ +-- vouch_beacon: Add a vouch vote to a beacon +-- ============================================================================ +CREATE OR REPLACE FUNCTION vouch_beacon(p_beacon_id UUID) +RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$ +DECLARE + current_user_id UUID; +BEGIN + current_user_id := auth.uid(); + + IF current_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + -- Insert or update the vote + INSERT INTO beacon_votes (user_id, beacon_id, vote_type) + VALUES (current_user_id, p_beacon_id, 'vouch') + ON CONFLICT (user_id, beacon_id) + DO UPDATE SET vote_type = 'vouch', created_at = NOW(); +END; +$$; + +-- ============================================================================ +-- report_beacon: Add a report vote to a beacon +-- ============================================================================ +CREATE OR REPLACE FUNCTION report_beacon(p_beacon_id UUID) +RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$ +DECLARE + current_user_id UUID; +BEGIN + current_user_id := auth.uid(); + + IF current_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + -- Insert or update the vote + INSERT INTO beacon_votes (user_id, beacon_id, vote_type) + VALUES (current_user_id, p_beacon_id, 'report') + ON CONFLICT (user_id, beacon_id) + DO UPDATE SET vote_type = 'report', created_at = NOW(); + + -- Decrease confidence score + UPDATE posts + SET confidence_score = GREATEST(0.0, confidence_score - 0.1) + WHERE id = p_beacon_id AND is_beacon = TRUE; +END; +$$; + +-- ============================================================================ +-- remove_beacon_vote: Remove user's vote from a beacon +-- ============================================================================ +CREATE OR REPLACE FUNCTION remove_beacon_vote(p_beacon_id UUID) +RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$ +DECLARE + current_user_id UUID; +BEGIN + current_user_id := auth.uid(); + + IF current_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + DELETE FROM beacon_votes + WHERE user_id = current_user_id + AND beacon_id = p_beacon_id; +END; +$$; + +-- ============================================================================ +-- garbage_collect_beacons: Admin function to disable stale beacons +-- ============================================================================ +CREATE OR REPLACE FUNCTION garbage_collect_beacons() +RETURNS INTEGER LANGUAGE plpgsql SECURITY DEFINER AS $$ +DECLARE + disabled_count INTEGER; +BEGIN + -- Disable beacons that are: + -- 1. Older than 6 hours + -- 2. Have low confidence score (< 0.3) + -- 3. Are still marked as active + UPDATE posts + SET is_active_beacon = FALSE + WHERE is_beacon = TRUE + AND is_active_beacon = TRUE + AND confidence_score < 0.3 + AND created_at < NOW() - INTERVAL '6 hours' + AND deleted_at IS NULL; + + GET DIAGNOSTICS disabled_count = ROW_COUNT; + RETURN disabled_count; +END; +$$; + +-- ============================================================================ +-- Update fetch_nearby_beacons to return Post-compatible structure +-- ============================================================================ +CREATE OR REPLACE FUNCTION fetch_nearby_beacons( + p_lat DOUBLE PRECISION, + p_long DOUBLE PRECISION, + p_radius INTEGER DEFAULT 16000 +) +RETURNS TABLE ( + id UUID, + body TEXT, + author_id UUID, + category_id UUID, + tone_label TEXT, + cis_score NUMERIC, + status TEXT, + created_at TIMESTAMPTZ, + edited_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + is_edited BOOLEAN, + allow_chain BOOLEAN, + chain_parent_id UUID, + image_url TEXT, + body_format TEXT, + background_id TEXT, + tags TEXT[], + is_beacon BOOLEAN, + beacon_type TEXT, + confidence_score NUMERIC, + is_active_beacon BOOLEAN, + latitude DOUBLE PRECISION, + longitude DOUBLE PRECISION, + distance_meters DOUBLE PRECISION, + author_handle TEXT, + author_display_name TEXT, + author_avatar_url TEXT, + vouch_count INTEGER, + report_count INTEGER, + status_color TEXT +) LANGUAGE plpgsql STABLE AS $$ +BEGIN + RETURN QUERY + SELECT + p.id, + p.body, + p.author_id, + p.category_id, + p.tone_label::TEXT, + p.cis_score, + p.status::TEXT, + p.created_at, + p.edited_at, + p.deleted_at, + (p.edited_at IS NOT NULL) AS is_edited, + p.allow_chain, + p.chain_parent_id, + p.image_url, + p.body_format, + p.background_id, + p.tags, + p.is_beacon, + p.beacon_type::TEXT, + p.confidence_score, + p.is_active_beacon, + ST_Y(p.location::geometry) AS latitude, + ST_X(p.location::geometry) AS longitude, + ST_Distance(p.location, ST_SetSRID(ST_MakePoint(p_long, p_lat), 4326)::geography) AS distance_meters, + prof.handle AS author_handle, + prof.display_name AS author_display_name, + prof.avatar_url AS author_avatar_url, + COALESCE(vouch.cnt, 0)::INT AS vouch_count, + COALESCE(report.cnt, 0)::INT AS report_count, + get_beacon_status_color(p.confidence_score) AS status_color + FROM posts p + LEFT JOIN profiles prof ON p.author_id = prof.id + LEFT JOIN ( + SELECT beacon_id, COUNT(*)::INT AS cnt + FROM beacon_votes + WHERE vote_type = 'vouch' + GROUP BY beacon_id + ) vouch ON p.id = vouch.beacon_id + LEFT JOIN ( + SELECT beacon_id, COUNT(*)::INT AS cnt + FROM beacon_votes + WHERE vote_type = 'report' + GROUP BY beacon_id + ) report ON p.id = report.beacon_id + WHERE p.is_beacon = TRUE + AND p.is_active_beacon = TRUE + AND p.deleted_at IS NULL + AND ST_DWithin( + p.location, + ST_SetSRID(ST_MakePoint(p_long, p_lat), 4326)::geography, + p_radius::DOUBLE PRECISION + ) + ORDER BY p.confidence_score DESC, p.created_at DESC + LIMIT 100; +END; +$$; diff --git a/_legacy/supabase/migrations/unified_beacon_post_functions_v3.sql b/_legacy/supabase/migrations/unified_beacon_post_functions_v3.sql new file mode 100644 index 0000000..53ccc40 --- /dev/null +++ b/_legacy/supabase/migrations/unified_beacon_post_functions_v3.sql @@ -0,0 +1,314 @@ +-- Migration: Unified Beacon/Post RPC Functions (v3 - complete cleanup) +-- Updates RPC functions to return Post-compatible JSON structures +-- This version properly handles all existing function signatures + +-- ============================================================================ +-- STEP 1: Drop all existing beacon-related functions +-- ============================================================================ + +-- Drop functions that might exist with various signatures +DROP FUNCTION IF EXISTS get_beacon_details(UUID); +DROP FUNCTION IF EXISTS fetch_nearby_beacons(DOUBLE PRECISION, DOUBLE PRECISION, INTEGER); +DROP FUNCTION IF EXISTS fetch_beacons(DOUBLE PRECISION, DOUBLE PRECISION, DOUBLE PRECISION, TEXT, INTEGER); +DROP FUNCTION IF EXISTS vouch_beacon(UUID); +DROP FUNCTION IF EXISTS report_beacon(UUID); +DROP FUNCTION IF EXISTS remove_beacon_vote(UUID); +DROP FUNCTION IF EXISTS garbage_collect_beacons(); + +-- ============================================================================ +-- STEP 2: Create unified beacon functions with Post-compatible structure +-- ============================================================================ + +-- ============================================================================ +-- get_beacon_details: Fetch a single beacon with full Post data structure +-- ============================================================================ +CREATE OR REPLACE FUNCTION get_beacon_details(p_beacon_id UUID) +RETURNS TABLE ( + id UUID, + body TEXT, + author_id UUID, + category_id UUID, + tone_label TEXT, + cis_score NUMERIC, + status TEXT, + created_at TIMESTAMPTZ, + edited_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + is_edited BOOLEAN, + allow_chain BOOLEAN, + chain_parent_id UUID, + image_url TEXT, + body_format TEXT, + background_id TEXT, + tags TEXT[], + is_beacon BOOLEAN, + beacon_type TEXT, + confidence_score NUMERIC, + is_active_beacon BOOLEAN, + latitude DOUBLE PRECISION, + longitude DOUBLE PRECISION, + distance_meters DOUBLE PRECISION, + author_handle TEXT, + author_display_name TEXT, + author_avatar_url TEXT, + vouch_count INTEGER, + report_count INTEGER, + user_vote TEXT, + status_color TEXT +) LANGUAGE plpgsql STABLE SECURITY DEFINER AS $$ +DECLARE + current_user_id UUID; +BEGIN + current_user_id := auth.uid(); + + RETURN QUERY + SELECT + p.id, + p.body, + p.author_id, + p.category_id, + p.tone_label::TEXT, + p.cis_score, + p.status::TEXT, + p.created_at, + p.edited_at, + p.deleted_at, + (p.edited_at IS NOT NULL) AS is_edited, + p.allow_chain, + p.chain_parent_id, + p.image_url, + p.body_format, + p.background_id, + p.tags, + p.is_beacon, + p.beacon_type::TEXT, + p.confidence_score, + p.is_active_beacon, + ST_Y(p.location::geometry) AS latitude, + ST_X(p.location::geometry) AS longitude, + 0.0 AS distance_meters, -- No distance calculation for single beacon fetch + prof.handle AS author_handle, + prof.display_name AS author_display_name, + prof.avatar_url AS author_avatar_url, + COALESCE(vouch.cnt, 0)::INT AS vouch_count, + COALESCE(report.cnt, 0)::INT AS report_count, + user_vote.vote_type AS user_vote, + get_beacon_status_color(p.confidence_score) AS status_color + FROM posts p + LEFT JOIN profiles prof ON p.author_id = prof.id + LEFT JOIN ( + SELECT beacon_id, COUNT(*)::INT AS cnt + FROM beacon_votes + WHERE vote_type = 'vouch' + GROUP BY beacon_id + ) vouch ON p.id = vouch.beacon_id + LEFT JOIN ( + SELECT beacon_id, COUNT(*)::INT AS cnt + FROM beacon_votes + WHERE vote_type = 'report' + GROUP BY beacon_id + ) report ON p.id = report.beacon_id + LEFT JOIN ( + SELECT beacon_id, vote_type + FROM beacon_votes + WHERE user_id = current_user_id + ) user_vote ON p.id = user_vote.beacon_id + WHERE p.id = p_beacon_id + AND p.is_beacon = TRUE + AND p.deleted_at IS NULL; +END; +$$; + +-- ============================================================================ +-- vouch_beacon: Add a vouch vote to a beacon +-- ============================================================================ +CREATE OR REPLACE FUNCTION vouch_beacon(p_beacon_id UUID) +RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$ +DECLARE + current_user_id UUID; +BEGIN + current_user_id := auth.uid(); + + IF current_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + -- Insert or update the vote + INSERT INTO beacon_votes (user_id, beacon_id, vote_type) + VALUES (current_user_id, p_beacon_id, 'vouch') + ON CONFLICT (user_id, beacon_id) + DO UPDATE SET vote_type = 'vouch', created_at = NOW(); +END; +$$; + +-- ============================================================================ +-- report_beacon: Add a report vote to a beacon +-- ============================================================================ +CREATE OR REPLACE FUNCTION report_beacon(p_beacon_id UUID) +RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$ +DECLARE + current_user_id UUID; +BEGIN + current_user_id := auth.uid(); + + IF current_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + -- Insert or update the vote + INSERT INTO beacon_votes (user_id, beacon_id, vote_type) + VALUES (current_user_id, p_beacon_id, 'report') + ON CONFLICT (user_id, beacon_id) + DO UPDATE SET vote_type = 'report', created_at = NOW(); + + -- Decrease confidence score + UPDATE posts + SET confidence_score = GREATEST(0.0, confidence_score - 0.1) + WHERE id = p_beacon_id AND is_beacon = TRUE; +END; +$$; + +-- ============================================================================ +-- remove_beacon_vote: Remove user's vote from a beacon +-- ============================================================================ +CREATE OR REPLACE FUNCTION remove_beacon_vote(p_beacon_id UUID) +RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$ +DECLARE + current_user_id UUID; +BEGIN + current_user_id := auth.uid(); + + IF current_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + DELETE FROM beacon_votes + WHERE user_id = current_user_id + AND beacon_id = p_beacon_id; +END; +$$; + +-- ============================================================================ +-- garbage_collect_beacons: Admin function to disable stale beacons +-- ============================================================================ +CREATE OR REPLACE FUNCTION garbage_collect_beacons() +RETURNS INTEGER LANGUAGE plpgsql SECURITY DEFINER AS $$ +DECLARE + disabled_count INTEGER; +BEGIN + -- Disable beacons that are: + -- 1. Older than 6 hours + -- 2. Have low confidence score (< 0.3) + -- 3. Are still marked as active + UPDATE posts + SET is_active_beacon = FALSE + WHERE is_beacon = TRUE + AND is_active_beacon = TRUE + AND confidence_score < 0.3 + AND created_at < NOW() - INTERVAL '6 hours' + AND deleted_at IS NULL; + + GET DIAGNOSTICS disabled_count = ROW_COUNT; + RETURN disabled_count; +END; +$$; + +-- ============================================================================ +-- fetch_nearby_beacons: Fetch beacons near a location (Post-compatible) +-- ============================================================================ +CREATE OR REPLACE FUNCTION fetch_nearby_beacons( + p_lat DOUBLE PRECISION, + p_long DOUBLE PRECISION, + p_radius INTEGER DEFAULT 16000 +) +RETURNS TABLE ( + id UUID, + body TEXT, + author_id UUID, + category_id UUID, + tone_label TEXT, + cis_score NUMERIC, + status TEXT, + created_at TIMESTAMPTZ, + edited_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + is_edited BOOLEAN, + allow_chain BOOLEAN, + chain_parent_id UUID, + image_url TEXT, + body_format TEXT, + background_id TEXT, + tags TEXT[], + is_beacon BOOLEAN, + beacon_type TEXT, + confidence_score NUMERIC, + is_active_beacon BOOLEAN, + latitude DOUBLE PRECISION, + longitude DOUBLE PRECISION, + distance_meters DOUBLE PRECISION, + author_handle TEXT, + author_display_name TEXT, + author_avatar_url TEXT, + vouch_count INTEGER, + report_count INTEGER, + status_color TEXT +) LANGUAGE plpgsql STABLE AS $$ +BEGIN + RETURN QUERY + SELECT + p.id, + p.body, + p.author_id, + p.category_id, + p.tone_label::TEXT, + p.cis_score, + p.status::TEXT, + p.created_at, + p.edited_at, + p.deleted_at, + (p.edited_at IS NOT NULL) AS is_edited, + p.allow_chain, + p.chain_parent_id, + p.image_url, + p.body_format, + p.background_id, + p.tags, + p.is_beacon, + p.beacon_type::TEXT, + p.confidence_score, + p.is_active_beacon, + ST_Y(p.location::geometry) AS latitude, + ST_X(p.location::geometry) AS longitude, + ST_Distance(p.location, ST_SetSRID(ST_MakePoint(p_long, p_lat), 4326)::geography) AS distance_meters, + prof.handle AS author_handle, + prof.display_name AS author_display_name, + prof.avatar_url AS author_avatar_url, + COALESCE(vouch.cnt, 0)::INT AS vouch_count, + COALESCE(report.cnt, 0)::INT AS report_count, + get_beacon_status_color(p.confidence_score) AS status_color + FROM posts p + LEFT JOIN profiles prof ON p.author_id = prof.id + LEFT JOIN ( + SELECT beacon_id, COUNT(*)::INT AS cnt + FROM beacon_votes + WHERE vote_type = 'vouch' + GROUP BY beacon_id + ) vouch ON p.id = vouch.beacon_id + LEFT JOIN ( + SELECT beacon_id, COUNT(*)::INT AS cnt + FROM beacon_votes + WHERE vote_type = 'report' + GROUP BY beacon_id + ) report ON p.id = report.beacon_id + WHERE p.is_beacon = TRUE + AND p.is_active_beacon = TRUE + AND p.deleted_at IS NULL + AND ST_DWithin( + p.location, + ST_SetSRID(ST_MakePoint(p_long, p_lat), 4326)::geography, + p_radius::DOUBLE PRECISION + ) + ORDER BY p.confidence_score DESC, p.created_at DESC + LIMIT 100; +END; +$$; diff --git a/_legacy/supabase/migrations/unified_beacon_post_functions_v4.sql b/_legacy/supabase/migrations/unified_beacon_post_functions_v4.sql new file mode 100644 index 0000000..424742e --- /dev/null +++ b/_legacy/supabase/migrations/unified_beacon_post_functions_v4.sql @@ -0,0 +1,328 @@ +-- Migration: Unified Beacon/Post RPC Functions (v4 - with CASCADE) +-- Updates RPC functions to return Post-compatible JSON structures +-- Uses CASCADE to ensure all dependencies are dropped + +-- ============================================================================ +-- STEP 1: Drop all existing beacon-related functions with CASCADE +-- ============================================================================ + +-- Drop get_beacon_details with all possible signatures using CASCADE +DROP FUNCTION IF EXISTS get_beacon_details(UUID) CASCADE; +DROP FUNCTION IF EXISTS get_beacon_details(p_beacon_id UUID) CASCADE; + +-- Drop fetch_nearby_beacons with all possible signatures using CASCADE +DROP FUNCTION IF EXISTS fetch_nearby_beacons(DOUBLE PRECISION, DOUBLE PRECISION, INTEGER) CASCADE; +DROP FUNCTION IF EXISTS fetch_nearby_beacons(p_lat DOUBLE PRECISION, p_long DOUBLE PRECISION, p_radius INTEGER) CASCADE; + +-- Drop fetch_beacons (the old function) using CASCADE +DROP FUNCTION IF EXISTS fetch_beacons(DOUBLE PRECISION, DOUBLE PRECISION, DOUBLE PRECISION, TEXT, INTEGER) CASCADE; +DROP FUNCTION IF EXISTS fetch_beacons(lat DOUBLE PRECISION, long DOUBLE PRECISION, radius_meters DOUBLE PRECISION, beacon_type_filter TEXT, limit_count INTEGER) CASCADE; + +-- Drop voting functions using CASCADE +DROP FUNCTION IF EXISTS vouch_beacon(UUID) CASCADE; +DROP FUNCTION IF EXISTS vouch_beacon(p_beacon_id UUID) CASCADE; +DROP FUNCTION IF EXISTS report_beacon(UUID) CASCADE; +DROP FUNCTION IF EXISTS report_beacon(p_beacon_id UUID) CASCADE; +DROP FUNCTION IF EXISTS remove_beacon_vote(UUID) CASCADE; +DROP FUNCTION IF EXISTS remove_beacon_vote(p_beacon_id UUID) CASCADE; + +-- Drop garbage collection function using CASCADE +DROP FUNCTION IF EXISTS garbage_collect_beacons() CASCADE; + +-- ============================================================================ +-- STEP 2: Create unified beacon functions with Post-compatible structure +-- ============================================================================ + +-- ============================================================================ +-- get_beacon_details: Fetch a single beacon with full Post data structure +-- ============================================================================ +CREATE FUNCTION get_beacon_details(p_beacon_id UUID) +RETURNS TABLE ( + id UUID, + body TEXT, + author_id UUID, + category_id UUID, + tone_label TEXT, + cis_score NUMERIC, + status TEXT, + created_at TIMESTAMPTZ, + edited_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + is_edited BOOLEAN, + allow_chain BOOLEAN, + chain_parent_id UUID, + image_url TEXT, + body_format TEXT, + background_id TEXT, + tags TEXT[], + is_beacon BOOLEAN, + beacon_type TEXT, + confidence_score NUMERIC, + is_active_beacon BOOLEAN, + latitude DOUBLE PRECISION, + longitude DOUBLE PRECISION, + distance_meters DOUBLE PRECISION, + author_handle TEXT, + author_display_name TEXT, + author_avatar_url TEXT, + vouch_count INTEGER, + report_count INTEGER, + user_vote TEXT, + status_color TEXT +) LANGUAGE plpgsql STABLE SECURITY DEFINER AS $$ +DECLARE + current_user_id UUID; +BEGIN + current_user_id := auth.uid(); + + RETURN QUERY + SELECT + p.id, + p.body, + p.author_id, + p.category_id, + p.tone_label::TEXT, + p.cis_score, + p.status::TEXT, + p.created_at, + p.edited_at, + p.deleted_at, + (p.edited_at IS NOT NULL) AS is_edited, + p.allow_chain, + p.chain_parent_id, + p.image_url, + p.body_format, + p.background_id, + p.tags, + p.is_beacon, + p.beacon_type::TEXT, + p.confidence_score::NUMERIC, + p.is_active_beacon, + ST_Y(p.location::geometry) AS latitude, + ST_X(p.location::geometry) AS longitude, + 0.0 AS distance_meters, -- No distance calculation for single beacon fetch + prof.handle AS author_handle, + prof.display_name AS author_display_name, + prof.avatar_url AS author_avatar_url, + COALESCE(vouch.cnt, 0)::INT AS vouch_count, + COALESCE(report.cnt, 0)::INT AS report_count, + user_vote.vote_type AS user_vote, + get_beacon_status_color(p.confidence_score) AS status_color + FROM posts p + LEFT JOIN profiles prof ON p.author_id = prof.id + LEFT JOIN ( + SELECT beacon_id, COUNT(*)::INT AS cnt + FROM beacon_votes + WHERE vote_type = 'vouch' + GROUP BY beacon_id + ) vouch ON p.id = vouch.beacon_id + LEFT JOIN ( + SELECT beacon_id, COUNT(*)::INT AS cnt + FROM beacon_votes + WHERE vote_type = 'report' + GROUP BY beacon_id + ) report ON p.id = report.beacon_id + LEFT JOIN ( + SELECT beacon_id, vote_type + FROM beacon_votes + WHERE user_id = current_user_id + ) user_vote ON p.id = user_vote.beacon_id + WHERE p.id = p_beacon_id + AND p.is_beacon = TRUE + AND p.deleted_at IS NULL; +END; +$$; + +-- ============================================================================ +-- vouch_beacon: Add a vouch vote to a beacon +-- ============================================================================ +CREATE FUNCTION vouch_beacon(p_beacon_id UUID) +RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$ +DECLARE + current_user_id UUID; +BEGIN + current_user_id := auth.uid(); + + IF current_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + -- Insert or update the vote + INSERT INTO beacon_votes (user_id, beacon_id, vote_type) + VALUES (current_user_id, p_beacon_id, 'vouch') + ON CONFLICT (user_id, beacon_id) + DO UPDATE SET vote_type = 'vouch', created_at = NOW(); +END; +$$; + +-- ============================================================================ +-- report_beacon: Add a report vote to a beacon +-- ============================================================================ +CREATE FUNCTION report_beacon(p_beacon_id UUID) +RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$ +DECLARE + current_user_id UUID; +BEGIN + current_user_id := auth.uid(); + + IF current_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + -- Insert or update the vote + INSERT INTO beacon_votes (user_id, beacon_id, vote_type) + VALUES (current_user_id, p_beacon_id, 'report') + ON CONFLICT (user_id, beacon_id) + DO UPDATE SET vote_type = 'report', created_at = NOW(); + + -- Decrease confidence score + UPDATE posts + SET confidence_score = GREATEST(0.0, confidence_score - 0.1) + WHERE id = p_beacon_id AND is_beacon = TRUE; +END; +$$; + +-- ============================================================================ +-- remove_beacon_vote: Remove user's vote from a beacon +-- ============================================================================ +CREATE FUNCTION remove_beacon_vote(p_beacon_id UUID) +RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$ +DECLARE + current_user_id UUID; +BEGIN + current_user_id := auth.uid(); + + IF current_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + DELETE FROM beacon_votes + WHERE user_id = current_user_id + AND beacon_id = p_beacon_id; +END; +$$; + +-- ============================================================================ +-- garbage_collect_beacons: Admin function to disable stale beacons +-- ============================================================================ +CREATE FUNCTION garbage_collect_beacons() +RETURNS INTEGER LANGUAGE plpgsql SECURITY DEFINER AS $$ +DECLARE + disabled_count INTEGER; +BEGIN + -- Disable beacons that are: + -- 1. Older than 6 hours + -- 2. Have low confidence score (< 0.3) + -- 3. Are still marked as active + UPDATE posts + SET is_active_beacon = FALSE + WHERE is_beacon = TRUE + AND is_active_beacon = TRUE + AND confidence_score < 0.3 + AND created_at < NOW() - INTERVAL '6 hours' + AND deleted_at IS NULL; + + GET DIAGNOSTICS disabled_count = ROW_COUNT; + RETURN disabled_count; +END; +$$; + +-- ============================================================================ +-- fetch_nearby_beacons: Fetch beacons near a location (Post-compatible) +-- ============================================================================ +CREATE FUNCTION fetch_nearby_beacons( + p_lat DOUBLE PRECISION, + p_long DOUBLE PRECISION, + p_radius INTEGER DEFAULT 16000 +) +RETURNS TABLE ( + id UUID, + body TEXT, + author_id UUID, + category_id UUID, + tone_label TEXT, + cis_score NUMERIC, + status TEXT, + created_at TIMESTAMPTZ, + edited_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + is_edited BOOLEAN, + allow_chain BOOLEAN, + chain_parent_id UUID, + image_url TEXT, + body_format TEXT, + background_id TEXT, + tags TEXT[], + is_beacon BOOLEAN, + beacon_type TEXT, + confidence_score NUMERIC, + is_active_beacon BOOLEAN, + latitude DOUBLE PRECISION, + longitude DOUBLE PRECISION, + distance_meters DOUBLE PRECISION, + author_handle TEXT, + author_display_name TEXT, + author_avatar_url TEXT, + vouch_count INTEGER, + report_count INTEGER, + status_color TEXT +) LANGUAGE plpgsql STABLE AS $$ +BEGIN + RETURN QUERY + SELECT + p.id, + p.body, + p.author_id, + p.category_id, + p.tone_label::TEXT, + p.cis_score, + p.status::TEXT, + p.created_at, + p.edited_at, + p.deleted_at, + (p.edited_at IS NOT NULL) AS is_edited, + p.allow_chain, + p.chain_parent_id, + p.image_url, + p.body_format, + p.background_id, + p.tags, + p.is_beacon, + p.beacon_type::TEXT, + p.confidence_score::NUMERIC, + p.is_active_beacon, + ST_Y(p.location::geometry) AS latitude, + ST_X(p.location::geometry) AS longitude, + ST_Distance(p.location, ST_SetSRID(ST_MakePoint(p_long, p_lat), 4326)::geography) AS distance_meters, + prof.handle AS author_handle, + prof.display_name AS author_display_name, + prof.avatar_url AS author_avatar_url, + COALESCE(vouch.cnt, 0)::INT AS vouch_count, + COALESCE(report.cnt, 0)::INT AS report_count, + get_beacon_status_color(p.confidence_score) AS status_color + FROM posts p + LEFT JOIN profiles prof ON p.author_id = prof.id + LEFT JOIN ( + SELECT beacon_id, COUNT(*)::INT AS cnt + FROM beacon_votes + WHERE vote_type = 'vouch' + GROUP BY beacon_id + ) vouch ON p.id = vouch.beacon_id + LEFT JOIN ( + SELECT beacon_id, COUNT(*)::INT AS cnt + FROM beacon_votes + WHERE vote_type = 'report' + GROUP BY beacon_id + ) report ON p.id = report.beacon_id + WHERE p.is_beacon = TRUE + AND p.is_active_beacon = TRUE + AND p.deleted_at IS NULL + AND ST_DWithin( + p.location, + ST_SetSRID(ST_MakePoint(p_long, p_lat), 4326)::geography, + p_radius::DOUBLE PRECISION + ) + ORDER BY p.confidence_score DESC, p.created_at DESC + LIMIT 100; +END; +$$; diff --git a/_legacy/supabase/migrations/update_search_function.sql b/_legacy/supabase/migrations/update_search_function.sql new file mode 100644 index 0000000..44b9e0b --- /dev/null +++ b/_legacy/supabase/migrations/update_search_function.sql @@ -0,0 +1,31 @@ +-- Update search_sojorn function to include post body search +-- This replaces the existing function with an enhanced version that searches: +-- 1. Users by handle and display name +-- 2. Tags from posts.tags array +-- 3. Posts by body content and tags + +CREATE OR REPLACE FUNCTION search_sojorn(p_query TEXT, limit_count INTEGER DEFAULT 10) +RETURNS JSON LANGUAGE plpgsql STABLE AS $$ +DECLARE result JSON; +BEGIN + SELECT json_build_object( + 'users', (SELECT json_agg(json_build_object('id', p.id, 'username', p.handle, 'display_name', p.display_name, 'avatar_url', p.avatar_url, 'harmony_tier', COALESCE(ts.tier, 'new'))) + FROM profiles p LEFT JOIN trust_state ts ON p.id = ts.user_id WHERE p.handle ILIKE '%' || p_query || '%' OR p.display_name ILIKE '%' || p_query || '%' LIMIT limit_count), + 'tags', (SELECT json_agg(json_build_object('tag', tag, 'count', cnt)) FROM ( + SELECT LOWER(UNNEST(tags)) AS tag, COUNT(*) AS cnt FROM posts WHERE tags IS NOT NULL AND deleted_at IS NULL + GROUP BY tag HAVING LOWER(tag) LIKE '%' || LOWER(p_query) || '%' ORDER BY cnt DESC LIMIT limit_count) t), + 'posts', (SELECT json_agg(json_build_object('id', post.id, 'body', post.body, 'author_id', post.author_id, 'author_handle', post.handle, 'author_display_name', post.display_name, 'created_at', post.created_at)) FROM ( + SELECT po.id, po.body, po.author_id, pr.handle, pr.display_name, po.created_at + FROM posts po + LEFT JOIN profiles pr ON po.author_id = pr.id + WHERE po.deleted_at IS NULL AND po.status = 'active' AND ( + po.body ILIKE '%' || p_query || '%' OR + EXISTS (SELECT 1 FROM UNNEST(po.tags) AS tag WHERE LOWER(tag) = LOWER(p_query)) + ) + ORDER BY po.created_at DESC + LIMIT limit_count + ) post) + ) INTO result; + RETURN result; +END; +$$; diff --git a/_legacy/supabase/setup_beacon_category.sql b/_legacy/supabase/setup_beacon_category.sql new file mode 100644 index 0000000..a19181b --- /dev/null +++ b/_legacy/supabase/setup_beacon_category.sql @@ -0,0 +1,9 @@ +-- Create the beacon_alerts category for beacon posts +-- Run this in your Supabase SQL Editor + +INSERT INTO categories (slug, name, description, is_sensitive) +VALUES ('beacon_alerts', 'Beacon Alerts', 'Community safety and alert posts', false) +ON CONFLICT (slug) DO NOTHING; + +-- Verify it was created +SELECT * FROM categories WHERE slug = 'beacon_alerts'; diff --git a/_legacy/supabase/setup_complete.sql b/_legacy/supabase/setup_complete.sql new file mode 100644 index 0000000..012a409 --- /dev/null +++ b/_legacy/supabase/setup_complete.sql @@ -0,0 +1,361 @@ +-- ============================================================================ +-- SOJORN DATABASE SETUP +-- Complete, idempotent schema for Sojorn social platform +-- ============================================================================ + +-- Extensions +DO $$ BEGIN CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; EXCEPTION WHEN duplicate_object THEN null; END $$; +DO $$ BEGIN CREATE EXTENSION IF NOT EXISTS "pg_trgm"; EXCEPTION WHEN duplicate_object THEN null; END $$; +DO $$ BEGIN CREATE EXTENSION IF NOT EXISTS "postgis"; EXCEPTION WHEN duplicate_object THEN null; END $$; + +-- Types +DO $$ BEGIN CREATE TYPE beacon_type AS ENUM ('police', 'checkpoint', 'taskForce', 'hazard', 'safety', 'community'); EXCEPTION WHEN duplicate_object THEN null; END $$; +DO $$ BEGIN CREATE TYPE trust_tier AS ENUM ('new', 'trusted', 'established'); EXCEPTION WHEN duplicate_object THEN null; END $$; +DO $$ BEGIN CREATE TYPE notification_type AS ENUM ('appreciate', 'chain', 'follow', 'comment', 'mention', 'follow_request', 'new_follower', 'request_accepted'); EXCEPTION WHEN duplicate_object THEN null; END $$; +DO $$ BEGIN CREATE TYPE tone_label AS ENUM ('positive', 'neutral', 'mixed', 'negative', 'hostile'); EXCEPTION WHEN duplicate_object THEN null; END $$; +DO $$ BEGIN CREATE TYPE post_status AS ENUM ('active', 'flagged', 'removed'); EXCEPTION WHEN duplicate_object THEN null; END $$; + +-- Tables +CREATE TABLE IF NOT EXISTS profiles ( + id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + handle TEXT UNIQUE NOT NULL CHECK (handle ~ '^[a-z0-9_]{3,20}$'), + display_name TEXT NOT NULL CHECK (length(trim(display_name)) >= 1 AND length(display_name) <= 50), + bio TEXT CHECK (length(bio) <= 300), + avatar_url TEXT, + cover_url TEXT, + is_official BOOLEAN NOT NULL DEFAULT FALSE, + beacon_enabled BOOLEAN NOT NULL DEFAULT FALSE, + location TEXT, + website TEXT, + interests TEXT[], + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS trust_state ( + user_id UUID PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE, + harmony_score INTEGER NOT NULL DEFAULT 50 CHECK (harmony_score >= 0 AND harmony_score <= 100), + tier trust_tier NOT NULL DEFAULT 'new', + posts_today INTEGER NOT NULL DEFAULT 0 CHECK (posts_today >= 0), + last_post_at TIMESTAMPTZ, + last_harmony_calc_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS categories ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + slug TEXT UNIQUE NOT NULL CHECK (slug ~ '^[a-z0-9_]{2,30}$'), + name TEXT NOT NULL CHECK (length(trim(name)) >= 1 AND length(name) <= 60), + description TEXT CHECK (length(description) <= 200), + is_sensitive BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS posts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + author_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + category_id UUID NOT NULL REFERENCES categories(id) ON DELETE CASCADE, + body TEXT NOT NULL CHECK (length(trim(body)) >= 1 AND length(body) <= 500), + status post_status NOT NULL DEFAULT 'active', + tone_label tone_label, + cis_score NUMERIC(3,2) CHECK (cis_score >= 0 AND cis_score <= 1), + image_url TEXT, + body_format TEXT DEFAULT 'plain' CHECK (body_format IN ('plain', 'markdown')), + background_id TEXT CHECK (background_id IN ('white', 'grey', 'blue', 'green', 'yellow', 'orange', 'red', 'purple', 'pink')), + tags TEXT[], + is_beacon BOOLEAN NOT NULL DEFAULT FALSE, + beacon_type beacon_type, + location geography(POINT), + confidence_score NUMERIC(3,2) CHECK (confidence_score >= 0 AND confidence_score <= 1), + is_active_beacon BOOLEAN DEFAULT TRUE, + allow_chain BOOLEAN NOT NULL DEFAULT TRUE, + chain_parent_id UUID REFERENCES posts(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + edited_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS post_metrics ( + post_id UUID PRIMARY KEY REFERENCES posts(id) ON DELETE CASCADE, + like_count INTEGER NOT NULL DEFAULT 0 CHECK (like_count >= 0), + save_count INTEGER NOT NULL DEFAULT 0 CHECK (save_count >= 0), + view_count INTEGER NOT NULL DEFAULT 0 CHECK (view_count >= 0), + comment_count INTEGER NOT NULL DEFAULT 0 CHECK (comment_count >= 0), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS post_likes ( + user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, post_id) +); + +CREATE TABLE IF NOT EXISTS post_saves ( + user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, post_id) +); + +CREATE TABLE IF NOT EXISTS comments ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + author_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + body TEXT NOT NULL CHECK (length(trim(body)) >= 1 AND length(body) <= 300), + status post_status NOT NULL DEFAULT 'active', + tone_label tone_label, + deleted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS beacon_votes ( + beacon_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + vote_type TEXT NOT NULL CHECK (vote_type IN ('vouch', 'report')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (beacon_id, user_id) +); + +CREATE TABLE IF NOT EXISTS notifications ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + type notification_type NOT NULL, + actor_id UUID REFERENCES profiles(id) ON DELETE SET NULL, + post_id UUID REFERENCES posts(id) ON DELETE SET NULL, + comment_id UUID REFERENCES comments(id) ON DELETE SET NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + is_read BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS follows ( + follower_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + following_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (follower_id, following_id), + CHECK (follower_id != following_id) +); + +CREATE TABLE IF NOT EXISTS blocks ( + blocker_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + blocked_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (blocker_id, blocked_id), + CHECK (blocker_id != blocked_id) +); + +CREATE TABLE IF NOT EXISTS reports ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + reporter_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + target_type TEXT NOT NULL CHECK (target_type IN ('post', 'comment', 'profile')), + target_id UUID NOT NULL, + reason TEXT NOT NULL CHECK (length(trim(reason)) >= 10 AND length(reason) <= 500), + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'reviewing', 'resolved', 'dismissed')), + reviewed_by UUID REFERENCES profiles(id), + reviewed_at TIMESTAMPTZ, + resolution_note TEXT CHECK (length(resolution_note) <= 1000), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (reporter_id, target_type, target_id) +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_profiles_handle ON profiles(handle); +CREATE INDEX IF NOT EXISTS idx_profiles_handle_trgm ON profiles USING GIN (handle gin_trgm_ops); +CREATE INDEX IF NOT EXISTS idx_profiles_beacon_enabled ON profiles(beacon_enabled) WHERE beacon_enabled = TRUE; +CREATE INDEX IF NOT EXISTS idx_posts_tags ON posts USING GIN (tags); +CREATE INDEX IF NOT EXISTS idx_posts_beacon_active ON posts(is_beacon, is_active_beacon) WHERE is_beacon = TRUE AND is_active_beacon = TRUE; +CREATE INDEX IF NOT EXISTS idx_posts_location ON posts USING GIST (location); + +-- Functions +CREATE OR REPLACE FUNCTION has_block_between(user_a UUID, user_b UUID) +RETURNS BOOLEAN LANGUAGE plpgsql STABLE SECURITY DEFINER AS $$ +BEGIN + IF user_a IS NULL OR user_b IS NULL THEN RETURN FALSE; END IF; + RETURN EXISTS (SELECT 1 FROM blocks WHERE (blocker_id = user_a AND blocked_id = user_b) OR (blocker_id = user_b AND blocked_id = user_a)); +END; +$$; + +CREATE OR REPLACE FUNCTION is_mutual_follow(user_a UUID, user_b UUID) +RETURNS BOOLEAN LANGUAGE plpgsql STABLE SECURITY DEFINER AS $$ +BEGIN + IF user_a IS NULL OR user_b IS NULL THEN RETURN FALSE; END IF; + RETURN EXISTS (SELECT 1 FROM follows WHERE follower_id = user_a AND following_id = user_b) + AND EXISTS (SELECT 1 FROM follows WHERE follower_id = user_b AND following_id = user_a); +END; +$$; + +CREATE OR REPLACE FUNCTION get_beacon_status_color(score NUMERIC) +RETURNS TEXT LANGUAGE plpgsql STABLE AS $$ +BEGIN + IF score > 0.7 THEN RETURN 'green'; + ELSIF score >= 0.3 THEN RETURN 'yellow'; + ELSE RETURN 'red'; END IF; +END; +$$; + +CREATE OR REPLACE FUNCTION search_sojorn(p_query TEXT, limit_count INTEGER DEFAULT 10) +RETURNS JSON LANGUAGE plpgsql STABLE AS $$ +DECLARE result JSON; +BEGIN + SELECT json_build_object( + 'users', (SELECT json_agg(json_build_object('id', p.id, 'username', p.handle, 'display_name', p.display_name, 'avatar_url', p.avatar_url, 'harmony_tier', COALESCE(ts.tier, 'new'))) + FROM profiles p LEFT JOIN trust_state ts ON p.id = ts.user_id WHERE p.handle ILIKE '%' || p_query || '%' OR p.display_name ILIKE '%' || p_query || '%' LIMIT limit_count), + 'tags', (SELECT json_agg(json_build_object('tag', tag, 'count', cnt)) FROM ( + SELECT LOWER(UNNEST(tags)) AS tag, COUNT(*) AS cnt FROM posts WHERE tags IS NOT NULL AND deleted_at IS NULL + GROUP BY tag HAVING LOWER(tag) LIKE '%' || LOWER(p_query) || '%' ORDER BY cnt DESC LIMIT limit_count) t), + 'posts', (SELECT json_agg(json_build_object('id', post.id, 'body', post.body, 'author_id', post.author_id, 'author_handle', post.handle, 'author_display_name', post.display_name, 'created_at', post.created_at)) FROM ( + SELECT po.id, po.body, po.author_id, pr.handle, pr.display_name, po.created_at + FROM posts po + LEFT JOIN profiles pr ON po.author_id = pr.id + WHERE po.deleted_at IS NULL AND po.status = 'active' AND ( + po.body ILIKE '%' || p_query || '%' OR + EXISTS (SELECT 1 FROM UNNEST(po.tags) AS tag WHERE LOWER(tag) = LOWER(p_query)) + ) + ORDER BY po.created_at DESC + LIMIT limit_count + ) post) + ) INTO result; + RETURN result; +END; +$$; + +CREATE OR REPLACE FUNCTION fetch_beacons(lat DOUBLE PRECISION, long DOUBLE PRECISION, radius_meters DOUBLE PRECISION DEFAULT 5000, beacon_type_filter TEXT DEFAULT NULL, limit_count INTEGER DEFAULT 50) +RETURNS TABLE ( + id UUID, body TEXT, author_id UUID, beacon_type TEXT, confidence_score NUMERIC, is_active_beacon BOOLEAN, created_at TIMESTAMPTZ, + distance_meters DOUBLE PRECISION, author_handle TEXT, author_display_name TEXT, author_avatar_url TEXT, vouch_count INTEGER, report_count INTEGER, status_color TEXT +) LANGUAGE plpgsql STABLE AS $$ +BEGIN + RETURN QUERY + SELECT p.id, p.body, p.author_id, p.beacon_type::TEXT, p.confidence_score, p.is_active_beacon, p.created_at, + ST_Distance(p.location, ST_SetSRID(ST_MakePoint(long, lat), 4326)::geography) AS distance_meters, + p.handle AS author_handle, p.display_name AS author_display_name, p.avatar_url AS author_avatar_url, + COALESCE(vouch.cnt, 0)::INT, COALESCE(report.cnt, 0)::INT, get_beacon_status_color(p.confidence_score) + FROM posts p + LEFT JOIN (SELECT beacon_id, COUNT(*)::INT AS cnt FROM beacon_votes WHERE vote_type = 'vouch' GROUP BY beacon_id) vouch ON p.id = vouch.beacon_id + LEFT JOIN (SELECT beacon_id, COUNT(*)::INT AS cnt FROM beacon_votes WHERE vote_type = 'report' GROUP BY beacon_id) report ON p.id = report.beacon_id + WHERE p.is_beacon = TRUE AND p.is_active_beacon = TRUE AND p.deleted_at IS NULL + AND (beacon_type_filter IS NULL OR p.beacon_type::TEXT = beacon_type_filter) + AND ST_DWithin(p.location, ST_SetSRID(ST_MakePoint(long, lat), 4326)::geography, radius_meters) + ORDER BY p.confidence_score DESC, p.created_at DESC LIMIT limit_count; +END; +$$; + +CREATE OR REPLACE FUNCTION handle_new_user() +RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER AS $$ +BEGIN + INSERT INTO public.profiles (id, handle, display_name) + VALUES (NEW.id, COALESCE(NEW.raw_user_meta_data->>'handle', NEW.email), COALESCE(NEW.raw_user_meta_data->>'display_name', NEW.email)); + INSERT INTO public.trust_state (user_id, harmony_score, tier, posts_today) VALUES (NEW.id, 50, 'new', 0); + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION init_post_metrics() +RETURNS TRIGGER LANGUAGE plpgsql AS $$ +BEGIN INSERT INTO post_metrics (post_id) VALUES (NEW.id); RETURN NEW; END; +$$; + +CREATE OR REPLACE FUNCTION update_post_like_count() +RETURNS TRIGGER LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN UPDATE post_metrics SET like_count = like_count + 1, updated_at = NOW() WHERE post_id = NEW.post_id; + ELSIF TG_OP = 'DELETE' THEN UPDATE post_metrics SET like_count = like_count - 1, updated_at = NOW() WHERE post_id = OLD.post_id; END IF; + RETURN NULL; +END; +$$; + +CREATE OR REPLACE FUNCTION update_post_save_count() +RETURNS TRIGGER LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN UPDATE post_metrics SET save_count = save_count + 1, updated_at = NOW() WHERE post_id = NEW.post_id; + ELSIF TG_OP = 'DELETE' THEN UPDATE post_metrics SET save_count = save_count - 1, updated_at = NOW() WHERE post_id = OLD.post_id; END IF; + RETURN NULL; +END; +$$; + +CREATE OR REPLACE FUNCTION update_post_comment_count() +RETURNS TRIGGER LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN UPDATE post_metrics SET comment_count = comment_count + 1, updated_at = NOW() WHERE post_id = NEW.post_id; + ELSIF TG_OP = 'DELETE' THEN UPDATE post_metrics SET comment_count = comment_count - 1, updated_at = NOW() WHERE post_id = OLD.post_id; END IF; + RETURN NULL; +END; +$$; + +CREATE OR REPLACE FUNCTION update_beacon_score_on_vouch() +RETURNS TRIGGER LANGUAGE plpgsql AS $$ +DECLARE voucher_trust INTEGER; +BEGIN + IF NEW.vote_type = 'vouch' THEN + SELECT COALESCE(ts.harmony_score, 50) INTO voucher_trust FROM trust_state ts WHERE ts.user_id = NEW.user_id; + UPDATE posts SET confidence_score = LEAST(1.0, confidence_score + (voucher_trust::NUMERIC / 1000)) WHERE id = NEW.beacon_id; + END IF; + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION prune_inactive_beacons() +RETURNS INTEGER LANGUAGE plpgsql AS $$ +DECLARE disabled_count INTEGER; +BEGIN + UPDATE posts SET is_active_beacon = FALSE WHERE is_beacon = TRUE AND is_active_beacon = TRUE + AND confidence_score < 0.3 AND created_at < NOW() - INTERVAL '10 minutes' AND deleted_at IS NULL; + GET DIAGNOSTICS disabled_count = ROW_COUNT; + RETURN disabled_count; +END; +$$; + +-- Triggers +CREATE TRIGGER handle_new_user AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION handle_new_user(); +CREATE TRIGGER init_metrics_on_post AFTER INSERT ON posts FOR EACH ROW EXECUTE FUNCTION init_post_metrics(); +CREATE TRIGGER update_like_count AFTER INSERT OR DELETE ON post_likes FOR EACH ROW EXECUTE FUNCTION update_post_like_count(); +CREATE TRIGGER update_save_count AFTER INSERT OR DELETE ON post_saves FOR EACH ROW EXECUTE FUNCTION update_post_save_count(); +CREATE TRIGGER update_comment_count AFTER INSERT OR DELETE ON comments FOR EACH ROW EXECUTE FUNCTION update_post_comment_count(); +CREATE TRIGGER update_beacon_score AFTER INSERT ON beacon_votes FOR EACH ROW EXECUTE FUNCTION update_beacon_score_on_vouch(); + +-- RLS +ALTER TABLE profiles ENABLE ROW LEVEL SECURITY; +ALTER TABLE trust_state ENABLE ROW LEVEL SECURITY; +ALTER TABLE categories ENABLE ROW LEVEL SECURITY; +ALTER TABLE posts ENABLE ROW LEVEL SECURITY; +ALTER TABLE post_metrics ENABLE ROW LEVEL SECURITY; +ALTER TABLE post_likes ENABLE ROW LEVEL SECURITY; +ALTER TABLE post_saves ENABLE ROW LEVEL SECURITY; +ALTER TABLE comments ENABLE ROW LEVEL SECURITY; +ALTER TABLE beacon_votes ENABLE ROW LEVEL SECURITY; +ALTER TABLE notifications ENABLE ROW LEVEL SECURITY; +ALTER TABLE follows ENABLE ROW LEVEL SECURITY; +ALTER TABLE blocks ENABLE ROW LEVEL SECURITY; +ALTER TABLE reports ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Public profiles" ON profiles FOR SELECT USING (true); +CREATE POLICY "Own profile" ON profiles FOR UPDATE USING (auth.uid() = id); +CREATE POLICY "Own trust state" ON trust_state FOR SELECT USING (auth.uid() = user_id); +CREATE POLICY "Categories are public" ON categories FOR SELECT USING (true); +CREATE POLICY "Anyone can insert categories" ON categories FOR INSERT WITH CHECK (true); +CREATE POLICY "Public posts" ON posts FOR SELECT USING (deleted_at IS NULL AND status = 'active'); +CREATE POLICY "Create posts" ON posts FOR INSERT WITH CHECK (auth.uid() = author_id); +CREATE POLICY "Own posts" ON posts FOR UPDATE USING (auth.uid() = author_id AND deleted_at IS NULL); +CREATE POLICY "Metrics" ON post_metrics FOR SELECT USING (true); +CREATE POLICY "Likes" ON post_likes FOR ALL USING (auth.uid() = user_id); +CREATE POLICY "Saves" ON post_saves FOR ALL USING (auth.uid() = user_id); +CREATE POLICY "Comments" ON comments FOR SELECT USING (deleted_at IS NULL AND status = 'active'); +CREATE POLICY "Create comments" ON comments FOR INSERT WITH CHECK (auth.uid() = author_id); +CREATE POLICY "Own comments" ON comments FOR UPDATE USING (auth.uid() = author_id AND deleted_at IS NULL); +CREATE POLICY "Beacon votes" ON beacon_votes FOR ALL USING (auth.uid() = user_id); +CREATE POLICY "Notifications" ON notifications FOR SELECT USING (auth.uid() = user_id); +CREATE POLICY "Follows" ON follows FOR SELECT USING (true); +CREATE POLICY "Manage follows" ON follows FOR ALL USING (auth.uid() = follower_id); +CREATE POLICY "Blocks" ON blocks FOR SELECT USING (auth.uid() = blocker_id); +CREATE POLICY "Manage blocks" ON blocks FOR ALL USING (auth.uid() = blocker_id); +CREATE POLICY "Reports" ON reports FOR SELECT USING (auth.uid() = reporter_id); + +-- Seed Data +INSERT INTO categories (slug, name, description, is_sensitive) VALUES + ('general', 'General', 'General discussion', false), + ('news', 'News', 'News and current events', false), + ('help', 'Help', 'Ask for help', false), + ('events', 'Events', 'Community events', false), + ('beacon-alerts', 'Beacon Alerts', 'Community safety alerts', false) +ON CONFLICT (slug) DO NOTHING; diff --git a/_legacy/supabase/test_beacon_column.sql b/_legacy/supabase/test_beacon_column.sql new file mode 100644 index 0000000..c073381 --- /dev/null +++ b/_legacy/supabase/test_beacon_column.sql @@ -0,0 +1,4 @@ +SELECT column_name, data_type, is_nullable, column_default +FROM information_schema.columns +WHERE table_name = 'profiles' +AND column_name = 'beacon_enabled'; diff --git a/admin/.gitignore b/admin/.gitignore new file mode 100644 index 0000000..e5f9122 --- /dev/null +++ b/admin/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.next/ +out/ +.env.local +.env*.local +*.tsbuildinfo +next-env.d.ts diff --git a/admin/README.md b/admin/README.md new file mode 100644 index 0000000..137cf27 --- /dev/null +++ b/admin/README.md @@ -0,0 +1,136 @@ +# Sojorn Admin Panel + +Secure administration frontend for the Sojorn social network platform. + +## Features + +- **Dashboard** — Real-time platform stats, user/post growth charts, quick actions +- **User Management** — Search, view, suspend, ban, verify, change roles, reset strikes +- **Post Management** — Browse, search, flag, remove, restore, view details +- **AI Moderation Queue** — Review AI-flagged content (OpenAI + Google Vision), approve/dismiss/remove/ban +- **Appeal System** — Full appeal workflow: review violations, approve/reject appeals, restore content +- **Reports** — Community reports management with action/dismiss workflow +- **Algorithm Settings** — Configure feed ranking weights and AI moderation thresholds +- **Categories** — Create, edit, manage content categories +- **System Health** — Database status, connection pool monitoring, audit log + +## Tech Stack + +- **Framework**: Next.js 14 (App Router) +- **Language**: TypeScript +- **Styling**: TailwindCSS +- **Charts**: Recharts +- **Icons**: Lucide React +- **Backend**: Go (Gin) REST API at `api.sojorn.net` + +## Setup + +```bash +# Install dependencies +npm install + +# Configure API endpoint +cp .env.local.example .env.local +# Edit NEXT_PUBLIC_API_URL if needed + +# Run development server +npm run dev +``` + +The admin panel runs on **port 3001** by default. + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `NEXT_PUBLIC_API_URL` | `https://api.sojorn.net` | Backend API base URL | + +## Authentication + +The admin panel uses the same JWT authentication as the main app. Users must have `role = 'admin'` in the `profiles` table to access admin endpoints. + +### Setting up an admin user + +```sql +-- On the VPS, connect to sojorn database +UPDATE profiles SET role = 'admin' WHERE handle = 'your_handle'; +``` + +## Backend API Routes + +All admin endpoints are under `/api/v1/admin/` and require: +1. Valid JWT token (Bearer auth) +2. User profile with `role = 'admin'` + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/admin/dashboard` | Platform stats | +| GET | `/admin/growth` | User/post growth data | +| GET | `/admin/users` | List users (search, filter) | +| GET | `/admin/users/:id` | User detail | +| PATCH | `/admin/users/:id/status` | Change user status | +| PATCH | `/admin/users/:id/role` | Change user role | +| PATCH | `/admin/users/:id/verification` | Toggle verification | +| POST | `/admin/users/:id/reset-strikes` | Reset strikes | +| GET | `/admin/posts` | List posts | +| GET | `/admin/posts/:id` | Post detail | +| PATCH | `/admin/posts/:id/status` | Change post status | +| DELETE | `/admin/posts/:id` | Delete post | +| GET | `/admin/moderation` | Moderation queue | +| PATCH | `/admin/moderation/:id/review` | Review flagged content | +| GET | `/admin/appeals` | List appeals | +| PATCH | `/admin/appeals/:id/review` | Review appeal | +| GET | `/admin/reports` | List reports | +| PATCH | `/admin/reports/:id` | Update report status | +| GET | `/admin/algorithm` | Get algorithm config | +| PUT | `/admin/algorithm` | Update algorithm config | +| GET | `/admin/categories` | List categories | +| POST | `/admin/categories` | Create category | +| PATCH | `/admin/categories/:id` | Update category | +| GET | `/admin/health` | System health check | +| GET | `/admin/audit-log` | Audit log | + +## Deployment + +```bash +# Build for production +npm run build + +# Start production server +npm start +``` + +For production, serve behind Nginx with SSL. Add a server block for `admin.sojorn.net`: + +```nginx +server { + listen 443 ssl http2; + server_name admin.sojorn.net; + + ssl_certificate /etc/letsencrypt/live/admin.sojorn.net/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/admin.sojorn.net/privkey.pem; + + location / { + proxy_pass http://localhost:3001; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } +} +``` + +## Moderation Flow + +``` +Content Created → AI Analysis (OpenAI/Google Vision) + ↓ +Score > threshold → Auto-flag → Moderation Queue + ↓ +Admin reviews → Approve / Dismiss / Remove Content / Ban User + ↓ +If removed → User sees violation → Can file appeal + ↓ +Admin reviews appeal → Approve (restore) / Reject (uphold) +``` diff --git a/admin/deploy_server.sh b/admin/deploy_server.sh new file mode 100644 index 0000000..4f2c054 --- /dev/null +++ b/admin/deploy_server.sh @@ -0,0 +1,217 @@ +#!/bin/bash +set -e + +echo "=== Sojorn Admin Panel Server Deployment ===" + +# 1. Run DB migration +echo "--- Running DB migration ---" +export PGPASSWORD="${PGPASSWORD:?Set PGPASSWORD before running this script}" + +psql -U postgres -h localhost -d sojorn <<'EOSQL' +-- Algorithm configuration table +CREATE TABLE IF NOT EXISTS public.algorithm_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + description TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Seed default algorithm config values +INSERT INTO public.algorithm_config (key, value, description) VALUES + ('feed_recency_weight', '0.4', 'Weight for post recency in feed ranking'), + ('feed_engagement_weight', '0.3', 'Weight for engagement metrics (likes, comments)'), + ('feed_harmony_weight', '0.2', 'Weight for author harmony/trust score'), + ('feed_diversity_weight', '0.1', 'Weight for content diversity in feed'), + ('moderation_auto_flag_threshold', '0.7', 'AI score threshold for auto-flagging content'), + ('moderation_auto_remove_threshold', '0.95', 'AI score threshold for automatic content removal'), + ('moderation_greed_keyword_threshold', '0.7', 'Keyword-based spam/greed detection threshold'), + ('feed_max_posts_per_author', '3', 'Max posts from same author in a single feed page'), + ('feed_boost_mutual_follow', '1.5', 'Multiplier boost for posts from mutual follows'), + ('feed_beacon_boost', '1.2', 'Multiplier boost for beacon posts in nearby feeds') +ON CONFLICT (key) DO NOTHING; + +-- Audit log table +CREATE TABLE IF NOT EXISTS public.audit_log ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + actor_id UUID REFERENCES public.profiles(id) ON DELETE SET NULL, + action TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id UUID, + details TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON public.audit_log(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_audit_log_actor_id ON public.audit_log(actor_id); +CREATE INDEX IF NOT EXISTS idx_audit_log_action ON public.audit_log(action); + +-- Ensure profiles.role column exists +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'profiles' AND column_name = 'role' + ) THEN + ALTER TABLE public.profiles ADD COLUMN role TEXT NOT NULL DEFAULT 'user'; + END IF; +END $$; + +-- Ensure profiles.is_verified column exists +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'profiles' AND column_name = 'is_verified' + ) THEN + ALTER TABLE public.profiles ADD COLUMN is_verified BOOLEAN DEFAULT FALSE; + END IF; +END $$; + +-- Ensure profiles.is_private column exists +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'profiles' AND column_name = 'is_private' + ) THEN + ALTER TABLE public.profiles ADD COLUMN is_private BOOLEAN DEFAULT FALSE; + END IF; +END $$; + +-- Ensure users.status column exists +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'status' + ) THEN + ALTER TABLE public.users ADD COLUMN status TEXT NOT NULL DEFAULT 'active'; + END IF; +END $$; + +-- Ensure users.last_login column exists +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'last_login' + ) THEN + ALTER TABLE public.users ADD COLUMN last_login TIMESTAMPTZ; + END IF; +END $$; +EOSQL + +echo "--- DB migration complete ---" + +# 2. Check/install Node.js +echo "--- Checking Node.js ---" +if ! command -v node &> /dev/null; then + echo "Installing Node.js 20..." + curl -fsSL https://deb.nodesource.com/setup_20.x | sudo bash - + sudo apt-get install -y nodejs +fi +echo "Node: $(node --version), npm: $(npm --version)" + +# 3. Pull latest code +echo "--- Pulling latest code ---" +cd /opt/sojorn +git pull origin main || echo "Git pull skipped (may not be configured)" + +# 4. Build Go backend +echo "--- Building Go backend ---" +cd /opt/sojorn/go-backend +go build -ldflags="-s -w" -o /opt/sojorn/bin/api ./cmd/api/main.go +echo "Go backend built successfully" + +# 5. Restart Go backend +echo "--- Restarting Go backend ---" +sudo systemctl restart sojorn-api +sleep 3 +sudo systemctl status sojorn-api --no-pager || true + +# 6. Setup admin frontend +echo "--- Setting up admin frontend ---" +mkdir -p /opt/sojorn/admin +cd /opt/sojorn/admin + +# Check if package.json exists (code should be pulled via git) +if [ ! -f package.json ]; then + echo "Admin frontend source not found at /opt/sojorn/admin" + echo "Please ensure the admin/ directory is in the git repo and pulled" + exit 1 +fi + +npm install --production=false +npx next build + +echo "--- Admin frontend built ---" + +# 7. Create .env.local for admin +cat > /opt/sojorn/admin/.env.local <<'EOF' +NEXT_PUBLIC_API_URL=https://api.sojorn.net +EOF + +# 8. Create systemd service for admin +sudo tee /etc/systemd/system/sojorn-admin.service > /dev/null <<'EOF' +[Unit] +Description=Sojorn Admin Panel +After=network.target sojorn-api.service + +[Service] +Type=simple +User=patrick +Group=patrick +WorkingDirectory=/opt/sojorn/admin +ExecStart=/usr/bin/npx next start --port 3001 +Restart=always +RestartSec=5 +Environment=NODE_ENV=production +EnvironmentFile=/opt/sojorn/admin/.env.local + +[Install] +WantedBy=multi-user.target +EOF + +sudo systemctl daemon-reload +sudo systemctl enable sojorn-admin +sudo systemctl restart sojorn-admin +sleep 3 +sudo systemctl status sojorn-admin --no-pager || true + +# 9. Setup Nginx +echo "--- Setting up Nginx for admin ---" +sudo tee /etc/nginx/sites-available/sojorn-admin > /dev/null <<'EOF' +server { + listen 80; + server_name admin.sojorn.net; + + location / { + proxy_pass http://localhost:3001; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } +} +EOF + +# Enable site if not already +if [ ! -L /etc/nginx/sites-enabled/sojorn-admin ]; then + sudo ln -s /etc/nginx/sites-available/sojorn-admin /etc/nginx/sites-enabled/ +fi + +sudo nginx -t +sudo systemctl reload nginx + +echo "=== Deployment complete! ===" +echo "Admin panel running on port 3001" +echo "Nginx configured for admin.sojorn.net" +echo "" +echo "NEXT STEPS:" +echo "1. Point admin.sojorn.net DNS A record to this server IP" +echo "2. Run: sudo certbot --nginx -d admin.sojorn.net" +echo "3. Set an admin user: psql -U postgres -h localhost -d sojorn -c \"UPDATE profiles SET role = 'admin' WHERE handle = 'your_handle';\"" diff --git a/admin/fix_service.sh b/admin/fix_service.sh new file mode 100644 index 0000000..7d6b493 --- /dev/null +++ b/admin/fix_service.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# Fix and restart the sojorn-admin service +# Run as: sudo bash /opt/sojorn/admin/fix_service.sh + +# 1. Remove old service completely +systemctl stop sojorn-admin 2>/dev/null +systemctl disable sojorn-admin 2>/dev/null + +# 2. Kill ANY process on port 3001 +fuser -k 3001/tcp 2>/dev/null +sleep 2 +# Double check +fuser -k 3001/tcp 2>/dev/null +sleep 1 + +# 3. Write fresh service file with Restart=on-failure +cat > /etc/systemd/system/sojorn-admin.service <<'SVCEOF' +[Unit] +Description=Sojorn Admin Panel +After=network.target sojorn-api.service + +[Service] +Type=simple +User=patrick +Group=patrick +WorkingDirectory=/opt/sojorn/admin +ExecStart=/usr/bin/node /opt/sojorn/admin/node_modules/next/dist/bin/next start --port 3001 +Restart=on-failure +RestartSec=15 +StartLimitIntervalSec=60 +StartLimitBurst=3 +Environment=NODE_ENV=production +Environment=NEXT_PUBLIC_API_URL=https://api.sojorn.net + +[Install] +WantedBy=multi-user.target +SVCEOF + +# 4. Reload and start +systemctl daemon-reload +systemctl enable sojorn-admin +systemctl start sojorn-admin + +sleep 4 +systemctl status sojorn-admin --no-pager diff --git a/admin/next.config.js b/admin/next.config.js new file mode 100644 index 0000000..251f5b6 --- /dev/null +++ b/admin/next.config.js @@ -0,0 +1,12 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + images: { + remotePatterns: [ + { protocol: 'https', hostname: 'img.sojorn.net' }, + { protocol: 'https', hostname: 'quips.sojorn.net' }, + { protocol: 'https', hostname: 'api.sojorn.net' }, + ], + }, +}; + +module.exports = nextConfig; diff --git a/admin/package-lock.json b/admin/package-lock.json new file mode 100644 index 0000000..e0e6950 --- /dev/null +++ b/admin/package-lock.json @@ -0,0 +1,2013 @@ +{ + "name": "sojorn-admin", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sojorn-admin", + "version": "1.0.0", + "dependencies": { + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "lucide-react": "^0.400.0", + "next": "^14.2.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "recharts": "^2.12.0", + "tailwind-merge": "^2.2.0" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "autoprefixer": "^10.4.17", + "postcss": "^8.4.33", + "tailwindcss": "^3.4.1", + "typescript": "^5.3.3" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@next/env": { + "version": "14.2.35", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.35.tgz", + "integrity": "sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz", + "integrity": "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz", + "integrity": "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz", + "integrity": "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz", + "integrity": "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz", + "integrity": "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz", + "integrity": "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz", + "integrity": "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz", + "integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz", + "integrity": "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, + "node_modules/@swc/helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", + "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.32", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.32.tgz", + "integrity": "sha512-Ez8QE4DMfhjjTsES9K2dwfV258qBui7qxUsoaixZDiTzbde4U12e1pXGNu/ECsUIOi5/zoCxAQxIhQnaUQ2VvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "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/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.24", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", + "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001766", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "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/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/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001769", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", + "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "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/chokidar/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/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/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/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "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/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "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/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "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==", + "license": "ISC" + }, + "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/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "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-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-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-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-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/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lucide-react": { + "version": "0.400.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.400.0.tgz", + "integrity": "sha512-rpp7pFHh3Xd93KHixNgB0SqThMHpYNzsGUu69UaQbSZ75Q/J3m5t6EhKyMT3m4w2WOxmJ2mY0tD3vebnXqQryQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "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/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "14.2.35", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz", + "integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==", + "license": "MIT", + "dependencies": { + "@next/env": "14.2.35", + "@swc/helpers": "0.5.5", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001579", + "graceful-fs": "^4.2.11", + "postcss": "8.4.31", + "styled-jsx": "5.1.1" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=18.17.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "14.2.33", + "@next/swc-darwin-x64": "14.2.33", + "@next/swc-linux-arm64-gnu": "14.2.33", + "@next/swc-linux-arm64-musl": "14.2.33", + "@next/swc-linux-x64-gnu": "14.2.33", + "@next/swc-linux-x64-musl": "14.2.33", + "@next/swc-win32-arm64-msvc": "14.2.33", + "@next/swc-win32-ia32-msvc": "14.2.33", + "@next/swc-win32-x64-msvc": "14.2.33" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "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/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "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/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "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/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "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/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "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/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", + "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "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/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + } + } +} diff --git a/admin/package.json b/admin/package.json new file mode 100644 index 0000000..3d62e25 --- /dev/null +++ b/admin/package.json @@ -0,0 +1,30 @@ +{ + "name": "sojorn-admin", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev --port 3001", + "build": "next build", + "start": "next start --port 3001", + "lint": "next lint" + }, + "dependencies": { + "next": "^14.2.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "lucide-react": "^0.400.0", + "recharts": "^2.12.0", + "clsx": "^2.1.0", + "tailwind-merge": "^2.2.0", + "class-variance-authority": "^0.7.0" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "autoprefixer": "^10.4.17", + "postcss": "^8.4.33", + "tailwindcss": "^3.4.1", + "typescript": "^5.3.3" + } +} diff --git a/admin/postcss.config.js b/admin/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/admin/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/admin/setup_nginx.sh b/admin/setup_nginx.sh new file mode 100644 index 0000000..ed997d7 --- /dev/null +++ b/admin/setup_nginx.sh @@ -0,0 +1,28 @@ +#!/bin/bash +cat > /tmp/sojorn-admin.conf << 'EOF' +server { + listen 80; + server_name admin.sojorn.net; + + location / { + proxy_pass http://127.0.0.1:3002; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } +} +EOF + +sudo cp /tmp/sojorn-admin.conf /etc/nginx/sites-available/sojorn-admin +sudo ln -sf /etc/nginx/sites-available/sojorn-admin /etc/nginx/sites-enabled/sojorn-admin +sudo nginx -t && sudo systemctl reload nginx +echo "--- Nginx status ---" +sudo systemctl status nginx --no-pager | head -5 +echo "--- Testing certbot ---" +sudo certbot --nginx -d admin.sojorn.net --non-interactive --agree-tos --redirect -m patrick@mp.ls +echo "--- Done ---" diff --git a/admin/setup_port3002.sh b/admin/setup_port3002.sh new file mode 100644 index 0000000..4b86ad6 --- /dev/null +++ b/admin/setup_port3002.sh @@ -0,0 +1,78 @@ +#!/bin/bash +set -e + +echo "=== Setting up Sojorn Admin on port 3002 ===" + +# Stop old service if running +systemctl stop sojorn-admin 2>/dev/null || true +systemctl disable sojorn-admin 2>/dev/null || true + +# Write service file for port 3002 +cat > /etc/systemd/system/sojorn-admin.service <<'SVCEOF' +[Unit] +Description=Sojorn Admin Panel +After=network.target + +[Service] +Type=simple +User=patrick +Group=patrick +WorkingDirectory=/opt/sojorn/admin +ExecStart=/usr/bin/node /opt/sojorn/admin/node_modules/next/dist/bin/next start --port 3002 +Restart=on-failure +RestartSec=30 +StartLimitIntervalSec=120 +StartLimitBurst=3 +Environment=NODE_ENV=production +Environment=NEXT_PUBLIC_API_URL=https://api.sojorn.net + +[Install] +WantedBy=multi-user.target +SVCEOF + +systemctl daemon-reload +systemctl enable sojorn-admin +systemctl start sojorn-admin + +echo "Waiting 5s for startup..." +sleep 5 + +echo "" +echo "=== Service status ===" +systemctl status sojorn-admin --no-pager + +echo "" +echo "=== Port check ===" +ss -tlnp | grep 3002 + +echo "" +echo "=== Setting up Nginx ===" + +cat > /etc/nginx/sites-available/sojorn-admin <<'NGXEOF' +server { + listen 80; + server_name admin.sojorn.net; + + location / { + proxy_pass http://127.0.0.1:3002; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } +} +NGXEOF + +if [ ! -L /etc/nginx/sites-enabled/sojorn-admin ]; then + ln -s /etc/nginx/sites-available/sojorn-admin /etc/nginx/sites-enabled/ +fi + +nginx -t && systemctl reload nginx +echo "Nginx configured for admin.sojorn.net -> port 3002" + +echo "" +echo "=== DONE ===" diff --git a/admin/setup_server.sh b/admin/setup_server.sh new file mode 100644 index 0000000..91315d4 --- /dev/null +++ b/admin/setup_server.sh @@ -0,0 +1,104 @@ +#!/bin/bash +set -e + +echo "=== Cleaning up all node processes on port 3001 ===" + +# Stop and fully remove any existing service +systemctl stop sojorn-admin 2>/dev/null || true +systemctl disable sojorn-admin 2>/dev/null || true + +# Kill ALL node processes related to the admin panel +pkill -9 -f "next start --port 3001" 2>/dev/null || true +pkill -9 -f "dist/server/entry.mjs" 2>/dev/null || true +sleep 2 + +# Double-kill anything left on port 3001 +fuser -k 3001/tcp 2>/dev/null || true +sleep 2 + +# Triple check +STILL_RUNNING=$(fuser 3001/tcp 2>/dev/null || true) +if [ -n "$STILL_RUNNING" ]; then + echo "Force killing PIDs: $STILL_RUNNING" + kill -9 $STILL_RUNNING 2>/dev/null || true + sleep 2 +fi + +echo "Port 3001 status:" +ss -tlnp | grep 3001 || echo "PORT IS FREE" + +echo "" +echo "=== Writing systemd service ===" + +cat > /etc/systemd/system/sojorn-admin.service <<'SVCEOF' +[Unit] +Description=Sojorn Admin Panel +After=network.target + +[Service] +Type=simple +User=patrick +Group=patrick +WorkingDirectory=/opt/sojorn/admin +ExecStart=/usr/bin/node /opt/sojorn/admin/node_modules/next/dist/bin/next start --port 3001 +Restart=on-failure +RestartSec=30 +StartLimitIntervalSec=120 +StartLimitBurst=3 +Environment=NODE_ENV=production +Environment=NEXT_PUBLIC_API_URL=https://api.sojorn.net + +[Install] +WantedBy=multi-user.target +SVCEOF + +systemctl daemon-reload +systemctl enable sojorn-admin +systemctl start sojorn-admin + +echo "Waiting 5s for startup..." +sleep 5 + +echo "" +echo "=== Service status ===" +systemctl status sojorn-admin --no-pager + +echo "" +echo "=== Port check ===" +ss -tlnp | grep 3001 + +echo "" +echo "=== Setting up Nginx ===" + +cat > /etc/nginx/sites-available/sojorn-admin <<'NGXEOF' +server { + listen 80; + server_name admin.sojorn.net; + + location / { + proxy_pass http://127.0.0.1:3001; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } +} +NGXEOF + +if [ ! -L /etc/nginx/sites-enabled/sojorn-admin ]; then + ln -s /etc/nginx/sites-available/sojorn-admin /etc/nginx/sites-enabled/ +fi + +nginx -t && systemctl reload nginx +echo "Nginx configured and reloaded" + +echo "" +echo "=== Checking Go API service ===" +systemctl status sojorn-api --no-pager || true + +echo "" +echo "=== DONE ===" diff --git a/admin/sojorn-admin.nginx b/admin/sojorn-admin.nginx new file mode 100644 index 0000000..41d4454 --- /dev/null +++ b/admin/sojorn-admin.nginx @@ -0,0 +1,16 @@ +server { + listen 80; + server_name admin.sojorn.net; + + location / { + proxy_pass http://127.0.0.1:3002; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } +} diff --git a/admin/src/app/ai-audit-log/page.tsx b/admin/src/app/ai-audit-log/page.tsx new file mode 100644 index 0000000..09a7190 --- /dev/null +++ b/admin/src/app/ai-audit-log/page.tsx @@ -0,0 +1,463 @@ +'use client'; + +import AdminShell from '@/components/AdminShell'; +import { api } from '@/lib/api'; +import { formatDateTime } from '@/lib/utils'; +import { useEffect, useState, useCallback } from 'react'; +import { + ScrollText, Search, ThumbsUp, ThumbsDown, Download, + ChevronLeft, ChevronRight, Filter, MessageSquare, FileText, + CheckCircle, XCircle, AlertTriangle, Eye, +} from 'lucide-react'; + +function ScoreBar({ label, value }: { label: string; value: number }) { + const pct = Math.round(value * 100); + const color = pct > 70 ? 'bg-red-500' : pct > 40 ? 'bg-yellow-500' : 'bg-green-500'; + return ( +
+ {label} +
+
+
+ {pct}% +
+ ); +} + +function DecisionBadge({ decision }: { decision: string }) { + const styles: Record = { + pass: 'bg-green-50 text-green-700 border-green-200', + flag: 'bg-red-50 text-red-700 border-red-200', + nsfw: 'bg-amber-50 text-amber-700 border-amber-200', + }; + const icons: Record = { + pass: , + flag: , + nsfw: , + }; + return ( + + {icons[decision]} {decision.toUpperCase()} + + ); +} + +function FeedbackBadge({ correct }: { correct: boolean | null }) { + if (correct === null || correct === undefined) { + return Not reviewed; + } + return correct ? ( + + Correct + + ) : ( + + Incorrect + + ); +} + +export default function AIAuditLogPage() { + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [page, setPage] = useState(0); + const limit = 25; + + // Filters + const [decisionFilter, setDecisionFilter] = useState(''); + const [contentTypeFilter, setContentTypeFilter] = useState(''); + const [feedbackFilter, setFeedbackFilter] = useState(''); + const [searchQuery, setSearchQuery] = useState(''); + const [searchInput, setSearchInput] = useState(''); + + // Feedback modal + const [feedbackId, setFeedbackId] = useState(null); + const [feedbackCorrect, setFeedbackCorrect] = useState(null); + const [feedbackReason, setFeedbackReason] = useState(''); + const [submitting, setSubmitting] = useState(false); + + // Expanded row + const [expandedId, setExpandedId] = useState(null); + + const fetchLog = useCallback(() => { + setLoading(true); + api.getAIModerationLog({ + limit, + offset: page * limit, + decision: decisionFilter || undefined, + content_type: contentTypeFilter || undefined, + search: searchQuery || undefined, + feedback: feedbackFilter || undefined, + }) + .then((data) => { + setItems(data.items || []); + setTotal(data.total || 0); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [page, decisionFilter, contentTypeFilter, feedbackFilter, searchQuery]); + + useEffect(() => { fetchLog(); }, [fetchLog]); + + const handleSearch = () => { + setPage(0); + setSearchQuery(searchInput); + }; + + const handleFeedbackSubmit = async () => { + if (!feedbackId || feedbackCorrect === null || !feedbackReason.trim()) return; + setSubmitting(true); + try { + await api.submitAIModerationFeedback(feedbackId, feedbackCorrect, feedbackReason); + setFeedbackId(null); + setFeedbackCorrect(null); + setFeedbackReason(''); + fetchLog(); + } catch (e: any) { + alert(`Failed: ${e.message}`); + } + setSubmitting(false); + }; + + const handleExport = async () => { + try { + const data = await api.exportAITrainingData(); + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `ai-training-data-${new Date().toISOString().slice(0, 10)}.json`; + a.click(); + URL.revokeObjectURL(url); + } catch (e: any) { + alert(`Export failed: ${e.message}`); + } + }; + + const totalPages = Math.ceil(total / limit); + + const feedbackPresets = [ + 'AI correctly identified harmful content', + 'AI correctly passed safe content', + 'False positive — content was actually fine', + 'False negative — content should have been flagged', + 'AI flagged satire/humor incorrectly', + 'Threshold too sensitive for this type of content', + 'AI missed context — cultural/religious reference', + ]; + + return ( + + {/* Header */} +
+
+

+ + AI Moderation Audit Log +

+

+ {total} decisions logged · Review AI decisions and provide training feedback +

+
+ +
+ + {/* Filters */} +
+
+ + + {/* Search */} +
+ setSearchInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + /> + +
+ + {/* Decision filter */} + + + {/* Content type filter */} + + + {/* Feedback filter */} + + + {(decisionFilter || contentTypeFilter || feedbackFilter || searchQuery) && ( + + )} +
+
+ + {/* Table */} + {loading ? ( +
+ {[...Array(5)].map((_, i) => ( +
+
+
+ ))} +
+ ) : items.length === 0 ? ( +
+ +

No audit log entries found

+

AI moderation decisions will appear here as content is created.

+
+ ) : ( + <> +
+ {items.map((item) => ( +
+ {/* Main row */} +
setExpandedId(expandedId === item.id ? null : item.id)} + > +
+
+ {/* Top badges */} +
+ + + {item.content_type === 'post' ? : } + {item.content_type} + + {item.flag_reason && ( + + {item.flag_reason} + + )} + + {formatDateTime(item.created_at)} + +
+ + {/* Content snippet */} +

+ {item.content_snippet || No content} +

+ + {/* Author */} +

+ By @{item.author_handle || '—'} + {item.author_display_name && ` (${item.author_display_name})`} +

+
+ + {/* Right side: scores + feedback status */} +
+
+ + + +
+
+ +
+
+
+
+ + {/* Expanded detail */} + {expandedId === item.id && ( +
+
+
+

Content ID

+

{item.content_id}

+
+
+

AI Provider

+

{item.ai_provider || 'openai'}

+
+ {item.or_decision && ( +
+

OpenRouter Decision

+

{item.or_decision}

+
+ )} + {item.feedback_reason && ( +
+

Admin Feedback

+

{item.feedback_reason}

+ {item.feedback_at && ( +

Reviewed {formatDateTime(item.feedback_at)}

+ )} +
+ )} +
+ + {/* Feedback form */} + {item.feedback_correct === null || item.feedback_correct === undefined ? ( + feedbackId === item.id ? ( +
+

Train the AI — Was this decision correct?

+ + {/* Correct / Incorrect toggle */} +
+ + +
+ + {/* Preset reasons */} +
+ {feedbackPresets + .filter(p => { + if (feedbackCorrect === true) return p.startsWith('AI correctly'); + if (feedbackCorrect === false) return !p.startsWith('AI correctly'); + return true; + }) + .map((preset) => ( + + ))} +
+ + {/* Custom reason */} +