├── .cursorrules ├── .env.example ├── .eslintrc.json ├── .github └── workflows │ ├── autofix.yml │ ├── e2e_tests.yml │ ├── submodule_update.yml │ └── unit_tests.yml ├── .gitignore ├── .prettierignore ├── .vscode └── launch.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── SECURITY.md ├── TRADEMARK_GUIDELINES.md ├── app ├── api │ ├── auth.ts │ ├── docs │ │ └── search │ │ │ └── route.ts │ ├── inngest │ │ └── route.ts │ ├── parse.ts │ ├── trpc │ │ └── [trpc] │ │ │ └── route.ts │ ├── v1 │ │ ├── ingest │ │ │ ├── route.ts │ │ │ ├── schema.ts │ │ │ └── user │ │ │ │ ├── route.ts │ │ │ │ └── schema.ts │ │ ├── moderate │ │ │ ├── route.ts │ │ │ └── schema.ts │ │ ├── records │ │ │ ├── [recordId] │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ └── users │ │ │ ├── [userId] │ │ │ ├── create_appeal │ │ │ │ ├── route.ts │ │ │ │ └── schema.ts │ │ │ └── route.ts │ │ │ └── route.ts │ └── webhooks │ │ └── clerk │ │ └── route.ts ├── appeal │ ├── actions.ts │ ├── form.tsx │ ├── page.tsx │ └── validation.ts ├── count-lazy.tsx ├── count.tsx ├── dashboard-moderations.png ├── dashboard-rules.png ├── dashboard-users.png ├── dashboard.png ├── dashboard │ ├── @sheet │ │ ├── (.)records │ │ │ └── [recordId] │ │ │ │ ├── moderations │ │ │ │ └── [moderationId] │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ ├── (.)users │ │ │ └── [userId] │ │ │ │ ├── actions │ │ │ │ └── [actionId] │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ ├── [...catchAll] │ │ │ └── page.tsx │ │ ├── default.tsx │ │ └── page.tsx │ ├── analytics │ │ ├── daily-analytics-chart.tsx │ │ ├── daily-section.tsx │ │ ├── hourly-analytics-chart.tsx │ │ ├── hourly-section.tsx │ │ ├── page.tsx │ │ ├── skeletons.tsx │ │ ├── totals-cards.tsx │ │ └── trends-section.tsx │ ├── auth.ts │ ├── developer │ │ ├── actions.tsx │ │ ├── key-creation-dialog.tsx │ │ ├── key-deletion-dialog.tsx │ │ ├── page.tsx │ │ └── settings.tsx │ ├── dynamic-layout.tsx │ ├── emails │ │ ├── actions.ts │ │ ├── form.tsx │ │ ├── page.tsx │ │ ├── preview.tsx │ │ └── schema.ts │ ├── inbox │ │ ├── [appealId] │ │ │ └── page.tsx │ │ ├── actions.ts │ │ ├── appeal-action-button.tsx │ │ ├── appeal-list.tsx │ │ ├── appeal.tsx │ │ ├── appeals.tsx │ │ ├── layout.tsx │ │ ├── page.tsx │ │ └── types.ts │ ├── layout.tsx │ ├── moderations │ │ ├── columns.tsx │ │ ├── data-table-toolbar.tsx │ │ ├── data-table.tsx │ │ └── page.tsx │ ├── page.tsx │ ├── records │ │ ├── [recordId] │ │ │ ├── moderations-table.tsx │ │ │ ├── moderations │ │ │ │ └── [moderationId] │ │ │ │ │ ├── moderation.tsx │ │ │ │ │ └── page.tsx │ │ │ ├── page.tsx │ │ │ ├── record-images.tsx │ │ │ └── record.tsx │ │ ├── action-menu.tsx │ │ ├── actions.ts │ │ └── types.tsx │ ├── rules │ │ ├── actions.ts │ │ ├── page.tsx │ │ ├── rule-dialog.tsx │ │ ├── rules.tsx │ │ ├── schema.ts │ │ └── strategies-list.tsx │ ├── settings │ │ ├── page.tsx │ │ └── settings.tsx │ ├── subscription │ │ ├── actions.ts │ │ ├── cancel │ │ │ └── page.tsx │ │ ├── manage-button.tsx │ │ ├── manage.tsx │ │ ├── not-admin.tsx │ │ ├── page.tsx │ │ ├── subscribe.tsx │ │ └── success │ │ │ └── page.tsx │ └── users │ │ ├── [userId] │ │ ├── actions-table.tsx │ │ ├── actions │ │ │ └── [actionId] │ │ │ │ ├── page.tsx │ │ │ │ └── user-action.tsx │ │ ├── page.tsx │ │ ├── records-table.tsx │ │ ├── stripe-account.tsx │ │ └── user-record.tsx │ │ ├── action-menu.tsx │ │ ├── actions.ts │ │ ├── columns.tsx │ │ ├── data-table-toolbar.tsx │ │ ├── data-table.tsx │ │ ├── page.tsx │ │ └── types.tsx ├── docs │ ├── [[...slug]] │ │ └── page.tsx │ └── layout.tsx ├── icon.tsx ├── iffy-image.tsx ├── iffy.png ├── layout.tsx ├── opengraph-image.tsx ├── page.tsx ├── sign-in │ └── [[...sign-in]] │ │ └── page.tsx ├── sign-out │ └── [[...sign-out]] │ │ └── page.tsx └── sign-up │ └── [[...sign-up]] │ └── page.tsx ├── components.json ├── components ├── antiwork-footer.tsx ├── code.tsx ├── copy-button.tsx ├── dashboard-tabs.tsx ├── date.tsx ├── debounced-input.tsx ├── logo.tsx ├── logos │ ├── buy-me-a-coffee.tsx │ └── gumroad.tsx ├── pricing.tsx ├── router-sheet.tsx ├── select-button.tsx ├── sheet │ ├── header.tsx │ └── section.tsx ├── trpc.tsx └── ui │ ├── alert-dialog.tsx │ ├── avatar.tsx │ ├── badge.tsx │ ├── button.tsx │ ├── card.tsx │ ├── chart.tsx │ ├── checkbox.tsx │ ├── collapsible.tsx │ ├── command.tsx │ ├── confirm.tsx │ ├── data-table-column-header.tsx │ ├── data-table-faceted-filter.tsx │ ├── data-table-infinite.tsx │ ├── data-table-loading.tsx │ ├── data-table-pagination.tsx │ ├── data-table-view-options.tsx │ ├── data-table.tsx │ ├── dialog.tsx │ ├── drawer.tsx │ ├── dropdown-menu.tsx │ ├── form.tsx │ ├── input.tsx │ ├── label.tsx │ ├── popover.tsx │ ├── resizable.tsx │ ├── scroll-area.tsx │ ├── select.tsx │ ├── separator.tsx │ ├── sheet.tsx │ ├── skeleton.tsx │ ├── slider.tsx │ ├── switch.tsx │ ├── table.tsx │ ├── tabs.tsx │ ├── textarea.tsx │ ├── toast.tsx │ ├── toaster.tsx │ ├── toggle-group.tsx │ ├── toggle.tsx │ └── tooltip.tsx ├── config.ts ├── content └── docs │ ├── (guides) │ ├── (features) │ │ ├── appeals.mdx │ │ ├── email-templates.mdx │ │ ├── rules-presets.mdx │ │ └── webhooks │ │ │ ├── events.mdx │ │ │ └── set-up.mdx │ ├── (root) │ │ └── quickstart.mdx │ ├── index.mdx │ └── meta.json │ ├── api-reference │ ├── (endpoints) │ │ ├── ingest │ │ │ ├── delete.mdx │ │ │ └── post.mdx │ │ └── moderate │ │ │ └── post.mdx │ ├── index.mdx │ └── meta.json │ └── meta.json ├── db ├── index.ts ├── relations.ts ├── schema.ts ├── seed │ ├── appeals.ts │ ├── index.ts │ ├── organization.ts │ ├── record-user-actions.ts │ ├── record-users.ts │ ├── records.ts │ ├── reset.ts │ └── rules.ts ├── tables.ts └── views.ts ├── docs ├── API.md ├── DASHBOARD.md └── OVERVIEW.md ├── drizzle.config.ts ├── drizzle ├── 0000_military_vanisher.sql ├── 0001_wet_revanche.sql ├── 0002_icy_killer_shrike.sql ├── 0003_happy_amazoness.sql ├── 0004_little_vulcan.sql ├── 0005_friendly_silver_fox.sql ├── 0006_robust_lyja.sql ├── 0007_condemned_mariko_yashida.sql ├── 0008_pretty_sabretooth.sql ├── 0009_robust_misty_knight.sql ├── 0010_certain_lady_mastermind.sql ├── 0011_lonely_blink.sql ├── 0012_dizzy_gladiator.sql ├── 0013_ordinary_lilith.sql ├── 0014_ambiguous_celestials.sql ├── 0015_stale_omega_flight.sql ├── 0016_narrow_donald_blake.sql ├── 0017_foamy_silver_centurion.sql ├── 0018_nifty_micromacro.sql ├── 0019_cool_forgotten_one.sql ├── 0020_slimy_starjammers.sql ├── 0021_condemned_captain_stacy.sql ├── 0022_aromatic_next_avengers.sql ├── 0023_bouncy_stone_men.sql ├── 0024_productive_black_knight.sql ├── 0025_careless_sandman.sql ├── 0026_petite_senator_kelly.sql ├── 0027_first_archangel.sql ├── 0028_striped_yellowjacket.sql ├── 0029_wet_firedrake.sql ├── 0030_odd_dracula.sql ├── meta │ ├── 0000_snapshot.json │ ├── 0001_snapshot.json │ ├── 0002_snapshot.json │ ├── 0003_snapshot.json │ ├── 0004_snapshot.json │ ├── 0005_snapshot.json │ ├── 0006_snapshot.json │ ├── 0007_snapshot.json │ ├── 0008_snapshot.json │ ├── 0009_snapshot.json │ ├── 0010_snapshot.json │ ├── 0011_snapshot.json │ ├── 0012_snapshot.json │ ├── 0013_snapshot.json │ ├── 0014_snapshot.json │ ├── 0015_snapshot.json │ ├── 0016_snapshot.json │ ├── 0017_snapshot.json │ ├── 0018_snapshot.json │ ├── 0019_snapshot.json │ ├── 0020_snapshot.json │ ├── 0021_snapshot.json │ ├── 0022_snapshot.json │ ├── 0023_snapshot.json │ ├── 0024_snapshot.json │ ├── 0025_snapshot.json │ ├── 0026_snapshot.json │ ├── 0027_snapshot.json │ ├── 0028_snapshot.json │ ├── 0029_snapshot.json │ ├── 0030_snapshot.json │ └── _journal.json └── routers │ └── record.ts ├── e2e ├── api.test.ts ├── index.test.ts └── visual.test.ts ├── emails ├── components │ ├── appeal-button.tsx │ └── default.tsx ├── render.tsx ├── templates │ ├── banned.ts │ ├── compliant.ts │ └── suspended.ts └── types.ts ├── hooks └── use-toast.ts ├── inngest ├── client.ts └── functions │ ├── analytics.ts │ ├── appeal-actions.ts │ ├── clerk.ts │ ├── index.ts │ ├── moderations.ts │ ├── records.ts │ └── user-actions.ts ├── lib ├── action-client.ts ├── badges.tsx ├── cache.ts ├── clerk.ts ├── crypto.ts ├── date.tsx ├── docs │ └── source.ts ├── env.ts ├── record.ts ├── stripe.ts ├── subscription-badge.tsx ├── trpc.ts ├── types.ts ├── url.ts ├── user-record.ts └── utils.ts ├── middleware.ts ├── next.config.mjs ├── openapi.json ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── presets ├── index.ts └── presets.ts ├── prettier.config.mjs ├── products ├── index.ts ├── products.ts └── types.ts ├── public ├── docs │ ├── appeals.png │ ├── create-api-key.png │ ├── create-webhook.png │ ├── developer-section.png │ ├── email-templates.png │ └── name-api-key.png ├── fonts │ ├── IBMPlexMono-Medium.ttf │ ├── IBMPlexSans-Medium.ttf │ └── Inter-Black.ttf ├── iffy-logo-dark.png ├── iffy-logo.eps ├── iffy-logo.png └── iffy-logo.svg ├── scripts └── generate-openapi-docs.ts ├── services ├── ai.ts ├── api-keys.ts ├── appeal-actions.ts ├── appeals.ts ├── audience.ts ├── auth.ts ├── config.ts ├── email.tsx ├── encrypt.ts ├── messages.ts ├── metadata.ts ├── moderations.ts ├── organizations.ts ├── records.ts ├── rules.ts ├── ruleset.ts ├── stripe │ ├── accounts.ts │ ├── subscriptions.ts │ └── usage.ts ├── url-moderation.ts ├── user-actions.ts ├── user-records.ts └── webhook.ts ├── shortest.config.ts ├── source.config.ts ├── strategies ├── blocklist.ts ├── classifier.ts ├── index.ts ├── prompt.ts └── types.ts ├── styles └── globals.css ├── tests └── blocklist.test.ts ├── trpc ├── context.ts ├── procedures │ └── protected.ts ├── routers │ ├── _app.ts │ ├── appeal.ts │ ├── record.ts │ └── user-record.ts └── trpc.ts ├── tsconfig.json └── vitest.config.ts /.cursorrules: -------------------------------------------------------------------------------- 1 | - Sentence case headers and buttons and stuff, not title case 2 | - Always write the code 3 | - Don’t leave comments in the code 4 | - Don’t apologize for errors, fix them 5 | - Only use type annotations when necessary -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Postgres 2 | POSTGRES_URL="postgresql://postgres:postgres@localhost:5432/iffy_development?sslmode=disable" 3 | POSTGRES_URL_NON_POOLING="postgresql://postgres:postgres@localhost:5432/iffy_development?sslmode=disable" 4 | 5 | # Encryption Secrets 6 | FIELD_ENCRYPTION_KEY="k1.aesgcm256.abc123" 7 | SECRET_KEY="abc123" 8 | 9 | # Clerk 10 | CLERK_SECRET_KEY="sk_test_abc123" 11 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="pk_test_abc123" 12 | NEXT_PUBLIC_CLERK_SIGN_IN_URL="/sign-in" 13 | NEXT_PUBLIC_CLERK_SIGN_UP_URL="/sign-up" 14 | NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL="/dashboard" 15 | NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL="/dashboard" 16 | SEED_CLERK_ORGANIZATION_ID="org_abc123" 17 | 18 | # Clerk Webhooks (Optional) 19 | CLERK_WEBHOOK_SECRET="whsec_abc123" 20 | 21 | # Inngest 22 | INNGEST_APP_NAME="iffy" 23 | 24 | # OpenAI 25 | OPENAI_API_KEY="sk-iffy-development-123" 26 | 27 | # Resend (Optional) 28 | RESEND_API_KEY="re_123" 29 | RESEND_FROM_NAME="Iffy" 30 | RESEND_FROM_EMAIL="no-reply@iffy.com" 31 | RESEND_AUDIENCE_ID="aud_123" 32 | 33 | # Testing (Optional) 34 | SHORTEST_ANTHROPIC_API_KEY="sk-ant-api03-123" 35 | MAILOSAUR_API_KEY="abc123" 36 | MAILOSAUR_SERVER_ID="abc123" 37 | 38 | # Signups (Optional) 39 | ENABLE_PUBLIC_SIGNUP=false 40 | 41 | # Billing (Optional) 42 | ENABLE_BILLING=false 43 | STRIPE_API_KEY="sk_test_abc123" 44 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next", 3 | "rules": { 4 | "@next/next/no-img-element": "off", 5 | "import/no-anonymous-default-export": "off" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.github/workflows/autofix.yml: -------------------------------------------------------------------------------- 1 | name: autofix.ci # needed to securely identify the workflow 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: ["main"] 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | autofix: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: 20 19 | cache: "npm" 20 | 21 | - run: npm ci 22 | 23 | - name: Lint 24 | run: npm run lint -- --fix 25 | shell: bash 26 | 27 | - name: Format 28 | run: npm run format -- --write 29 | shell: bash 30 | 31 | - name: Typecheck 32 | shell: bash 33 | run: | 34 | set -eo pipefail 35 | export NODE_OPTIONS="--max_old_space_size=4096" 36 | npm run typecheck 37 | 38 | - uses: autofix-ci/action@ff86a557419858bb967097bfc916833f5647fa8c 39 | -------------------------------------------------------------------------------- /.github/workflows/e2e_tests.yml: -------------------------------------------------------------------------------- 1 | name: "End-to-end tests" 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.ref }} 9 | cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} 10 | 11 | jobs: 12 | shortest: 13 | runs-on: ubuntu-latest 14 | timeout-minutes: 10 15 | 16 | env: 17 | VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} 18 | VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} 19 | VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - uses: actions/setup-node@v4 25 | with: 26 | node-version: 20 27 | cache: npm 28 | cache-dependency-path: package-lock.json 29 | 30 | - name: Install dependencies 31 | run: npm ci 32 | 33 | - name: Install Vercel CLI 34 | run: npm i -g vercel 35 | 36 | - name: Pull and set up .env.local 37 | run: | 38 | vercel env pull --environment=preview --token=$VERCEL_TOKEN 39 | test -f .env.local || (echo ".env.local not created" && exit 1) 40 | 41 | - name: Wait for Vercel preview deployment 42 | uses: patrickedqvist/wait-for-vercel-preview@v1.3.2 43 | id: waitFor 44 | with: 45 | token: ${{ secrets.GITHUB_TOKEN }} 46 | max_timeout: 300 47 | 48 | - name: Install Playwright 49 | run: npx playwright install chromium 50 | 51 | - name: Run Shortest tests 52 | id: shortest_tests 53 | run: | 54 | export $(cat .env.local | grep -v '^#' | xargs) 55 | npm run shortest -- --headless --log-level=trace --target=${{ steps.waitFor.outputs.url }} 56 | env: 57 | BASE_URL: ${{ steps.waitFor.outputs.url }} 58 | 59 | - name: Upload artifacts 60 | if: ${{ failure() && steps.shortest_tests.conclusion == 'failure' }} 61 | uses: actions/upload-artifact@v4 62 | with: 63 | name: artifacts 64 | path: .shortest 65 | if-no-files-found: error 66 | include-hidden-files: true 67 | retention-days: 7 68 | -------------------------------------------------------------------------------- /.github/workflows/submodule_update.yml: -------------------------------------------------------------------------------- 1 | name: Submodule Update 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | build: 9 | name: Submodule update 10 | runs-on: ubuntu-latest 11 | env: 12 | PARENT_ORG: 'antiwork' 13 | PARENT_REPOSITORY: 'iffy-cloud' 14 | CHECKOUT_BRANCH: 'main' 15 | PR_AGAINST_BRANCH: 'main' 16 | TITLE_PREFIX: 'Updates Iffy submodule to version' 17 | 18 | steps: 19 | - name: Checkout Code 20 | uses: actions/checkout@v4 21 | 22 | - name: Checkout parent repository and branch 23 | uses: actions/checkout@v4 24 | with: 25 | token: ${{ secrets._GITHUB_ACCESS_TOKEN }} 26 | repository: ${{ env.PARENT_ORG }}/${{ env.PARENT_REPOSITORY }} 27 | ref: ${{ env.CHECKOUT_BRANCH }} 28 | submodules: true 29 | fetch-depth: 0 30 | 31 | - uses: actions/setup-node@v4 32 | with: 33 | node-version: 20 34 | cache: npm 35 | cache-dependency-path: package-lock.json 36 | 37 | - name: Create new branch and push changes 38 | shell: bash 39 | run: | 40 | git config user.name ${{ secrets.GIT_USER_NAME }} 41 | git config user.email ${{ secrets.GIT_USER_EMAIL }} 42 | git submodule update --remote 43 | npm i 44 | git checkout -b $GITHUB_RUN_ID 45 | git commit -am "updating submodules" 46 | git push --set-upstream origin $GITHUB_RUN_ID 47 | 48 | - name: Create pull request against target branch 49 | uses: actions/github-script@v5 50 | with: 51 | github-token: ${{ secrets._GITHUB_ACCESS_TOKEN }} 52 | script: | 53 | await github.rest.pulls.create({ 54 | owner: '${{ env.PARENT_ORG }}', 55 | repo: '${{ env.PARENT_REPOSITORY }}', 56 | head: process.env.GITHUB_RUN_ID, 57 | base: '${{ env.PR_AGAINST_BRANCH }}', 58 | title: `${process.env.TITLE_PREFIX} ${process.env.GITHUB_RUN_ID}`, 59 | body: `${process.env.TITLE_PREFIX} \`${process.env.GITHUB_RUN_ID}\``, 60 | }); 61 | -------------------------------------------------------------------------------- /.github/workflows/unit_tests.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | on: 3 | pull_request: 4 | branches: [main] 5 | jobs: 6 | test: 7 | timeout-minutes: 60 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: actions/setup-node@v4 12 | with: 13 | node-version: lts/* 14 | 15 | - name: Install dependencies 16 | run: npm install 17 | 18 | - name: Run tests 19 | run: npm run test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | node_modules/ 38 | /test-results/ 39 | /blob-report/ 40 | 41 | # shortest 42 | /.shortest/ 43 | 44 | # fumadocs 45 | /.source/ -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | public 2 | drizzle/meta 3 | .github/ 4 | README.md 5 | content/docs/api-reference/(endpoints)/ -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Next.js: debug server-side", 6 | "type": "node-terminal", 7 | "request": "launch", 8 | "command": "npm run dev" 9 | }, 10 | { 11 | "name": "Next.js: debug client-side", 12 | "type": "chrome", 13 | "request": "launch", 14 | "url": "http://localhost:3000" 15 | }, 16 | { 17 | "name": "Next.js: debug full stack", 18 | "type": "node", 19 | "request": "launch", 20 | "program": "${workspaceFolder}/node_modules/.bin/next", 21 | "runtimeArgs": ["--inspect"], 22 | "skipFiles": ["/**"], 23 | "serverReadyAction": { 24 | "action": "debugWithEdge", 25 | "killOnServerStop": true, 26 | "pattern": "- Local:.+(https?://.+)", 27 | "uriFormat": "%s", 28 | "webRoot": "${workspaceFolder}" 29 | } 30 | } 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024-present Gumroad, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | We take the security of Iffy and its users seriously. If you believe you have found a security vulnerability, please report it to us as described below. 6 | 7 | **Please do not report security vulnerabilities through public GitHub issues.** 8 | 9 | Instead, please report them via email to hi@gumroad.com. You should receive a response within 48 hours. If for some reason you do not, please follow up via email to ensure we received your original message. 10 | 11 | When submitting your vulnerability report, please provide the following details: 12 | 13 | - Vulnerability category (for example, buffer overflow, SQL injection, cross-site scripting, etc.) 14 | - Complete file paths for the source file(s) where the issue appears 15 | - A reference to the affected source code (such as a tag, branch, commit, or direct URL) 16 | - Any specific configuration settings necessary to replicate the issue 17 | - A detailed sequence of steps required to reproduce the issue 18 | - If available, a sample proof-of-concept or exploit code 19 | - An explanation of the vulnerability's potential impact, including how an attacker might exploit it 20 | 21 | ## Preferred Languages 22 | 23 | We prefer all communications to be in English. 24 | 25 | ## Security Update Policy 26 | 27 | When we receive a security bug report, we will: 28 | 29 | - Confirm the problem and determine the affected versions 30 | - Audit code to find any potential similar problems 31 | - Prepare fixes for all affected versions 32 | - Release new security fix versions 33 | -------------------------------------------------------------------------------- /app/api/auth.ts: -------------------------------------------------------------------------------- 1 | import { validateApiKey } from "@/services/api-keys"; 2 | import { NextRequest } from "next/server"; 3 | 4 | export async function authenticateRequest( 5 | req: NextRequest, 6 | ): Promise<[isValid: false, clerkOrganizationId: null] | [isValid: true, clerkOrganizationId: string]> { 7 | const authHeader = req.headers.get("Authorization"); 8 | if (!authHeader || !authHeader.startsWith("Bearer ")) { 9 | return [false, null]; 10 | } 11 | const apiKey = authHeader.split(" ")[1]; 12 | const clerkOrganizationId = await validateApiKey(apiKey); 13 | if (!clerkOrganizationId) { 14 | return [false, null]; 15 | } 16 | return [true, clerkOrganizationId]; 17 | } 18 | -------------------------------------------------------------------------------- /app/api/docs/search/route.ts: -------------------------------------------------------------------------------- 1 | import { source } from "@/lib/docs/source"; 2 | import { createFromSource } from "fumadocs-core/search/server"; 3 | 4 | export const { GET } = createFromSource(source); 5 | -------------------------------------------------------------------------------- /app/api/inngest/route.ts: -------------------------------------------------------------------------------- 1 | import { inngest as client } from "@/inngest/client"; 2 | import functions from "@/inngest/functions"; 3 | import { serve } from "inngest/next"; 4 | 5 | export const maxDuration = 300; 6 | 7 | export const { GET, POST, PUT } = serve({ 8 | client, 9 | functions, 10 | }); 11 | -------------------------------------------------------------------------------- /app/api/parse.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest } from "next/server"; 2 | import { ZodSchema, z } from "zod"; 3 | import { fromZodError } from "zod-validation-error"; 4 | 5 | export async function parseRequestBody( 6 | req: NextRequest, 7 | schema: ZodSchema, 8 | adapter?: (data: unknown) => unknown, 9 | ): Promise<{ data: TOutput; error?: never } | { data?: never; error: { message: string } }> { 10 | try { 11 | let body: unknown; 12 | try { 13 | body = await req.json(); 14 | } catch { 15 | body = {}; 16 | } 17 | 18 | if (adapter) { 19 | body = adapter(body); 20 | } 21 | const result = schema.safeParse(body); 22 | if (result.success) { 23 | return { data: result.data }; 24 | } 25 | const { message } = fromZodError(result.error); 26 | return { error: { message } }; 27 | } catch (error) { 28 | return { error: { message: "Invalid request body" } }; 29 | } 30 | } 31 | 32 | export async function parseQueryParams( 33 | req: NextRequest, 34 | schema: ZodSchema, 35 | adapter?: (data: Record) => Record, 36 | ): Promise<{ data: TOutput; error?: never } | { data?: never; error: { message: string } }> { 37 | try { 38 | let query = Object.fromEntries(req.nextUrl.searchParams); 39 | 40 | if (adapter) { 41 | query = adapter(query); 42 | } 43 | const result = schema.safeParse(query); 44 | if (result.success) { 45 | return { data: result.data }; 46 | } 47 | const { message } = fromZodError(result.error); 48 | return { error: { message } }; 49 | } catch (error) { 50 | return { error: { message: "Invalid query parameters" } }; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/api/trpc/[trpc]/route.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "@/trpc/context"; 2 | import { appRouter } from "@/trpc/routers/_app"; 3 | import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; 4 | 5 | export const maxDuration = 30; 6 | 7 | const handler = (request: Request) => { 8 | return fetchRequestHandler({ 9 | endpoint: "/api/trpc", 10 | req: request, 11 | router: appRouter, 12 | createContext, 13 | }); 14 | }; 15 | 16 | export { handler as GET, handler as POST }; 17 | -------------------------------------------------------------------------------- /app/api/v1/ingest/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { isValidMetadata } from "@/services/metadata"; 3 | 4 | const MetadataSchema = z.record(z.string(), z.unknown()).refine(isValidMetadata, { 5 | message: 6 | "Metadata keys can't be more than 40 characters or include '[' or ']'. Metadata values must be serializable and can't be more than 500 characters. The total number of keys can't be more than 50.", 7 | }); 8 | 9 | export const IngestUpdateRequestData = z 10 | .object({ 11 | clientId: z.string(), 12 | clientUrl: z.string().url().optional(), 13 | name: z.string(), 14 | entity: z.string(), 15 | content: z.union([ 16 | z.string(), 17 | z.object({ 18 | text: z.string(), 19 | imageUrls: z.array(z.string().url()).optional(), 20 | externalUrls: z.array(z.string().url()).optional(), 21 | }), 22 | ]), 23 | metadata: MetadataSchema.optional(), 24 | user: z 25 | .object({ 26 | clientId: z.string(), 27 | clientUrl: z.string().url().optional(), 28 | stripeAccountId: z.string().optional(), 29 | email: z.string().optional(), 30 | name: z.string().optional(), 31 | username: z.string().optional(), 32 | protected: z.boolean().optional(), 33 | metadata: MetadataSchema.optional(), 34 | }) 35 | .optional(), 36 | }) 37 | .strict(); 38 | 39 | export type IngestUpdateRequestData = z.infer; 40 | 41 | function isRecord(value: unknown): value is Record { 42 | return typeof value === "object" && value !== null; 43 | } 44 | 45 | export const ingestUpdateAdapter = (data: unknown) => { 46 | if (!isRecord(data)) { 47 | return data; 48 | } 49 | const { text, fileUrls, ...rest } = data; 50 | return { content: { text, imageUrls: fileUrls }, ...rest }; 51 | }; 52 | 53 | export const IngestDeleteRequestData = z 54 | .object({ 55 | clientId: z.string(), 56 | }) 57 | .strict(); 58 | 59 | export type IngestDeleteRequestData = z.infer; 60 | -------------------------------------------------------------------------------- /app/api/v1/ingest/user/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { IngestUserRequestData } from "./schema"; 3 | import { parseRequestBody } from "@/app/api/parse"; 4 | import { createOrUpdateUserRecord } from "@/services/user-records"; 5 | import { authenticateRequest } from "@/app/api/auth"; 6 | 7 | export async function POST(req: NextRequest) { 8 | const [isValid, clerkOrganizationId] = await authenticateRequest(req); 9 | if (!isValid) { 10 | return NextResponse.json({ error: { message: "Invalid API key" } }, { status: 401 }); 11 | } 12 | 13 | const { data, error } = await parseRequestBody(req, IngestUserRequestData); 14 | if (error) { 15 | return NextResponse.json({ error }, { status: 400 }); 16 | } 17 | 18 | const userRecord = await createOrUpdateUserRecord({ 19 | clerkOrganizationId, 20 | clientId: data.clientId, 21 | clientUrl: data.clientUrl, 22 | email: data.email, 23 | name: data.name, 24 | username: data.username, 25 | initialProtected: data.protected, 26 | stripeAccountId: data.stripeAccountId, 27 | metadata: data.metadata, 28 | }); 29 | 30 | return NextResponse.json({ id: userRecord.id, message: "Success" }, { status: 200 }); 31 | } 32 | -------------------------------------------------------------------------------- /app/api/v1/ingest/user/schema.ts: -------------------------------------------------------------------------------- 1 | import { isValidMetadata } from "@/services/metadata"; 2 | import { z } from "zod"; 3 | 4 | const MetadataSchema = z.record(z.string(), z.unknown()).refine(isValidMetadata, { 5 | message: 6 | "Metadata keys can't be more than 40 characters or include '[' or ']'. Metadata values must be serializable and can't be more than 500 characters. The total number of keys can't be more than 50.", 7 | }); 8 | 9 | export const IngestUserRequestData = z 10 | .object({ 11 | clientId: z.string(), 12 | clientUrl: z.string().url().optional(), 13 | stripeAccountId: z.string().optional(), 14 | email: z.string().optional(), 15 | name: z.string().optional(), 16 | username: z.string().optional(), 17 | protected: z.boolean().optional(), 18 | metadata: MetadataSchema.optional(), 19 | }) 20 | .strict(); 21 | 22 | export type IngestUserRequestData = z.infer; 23 | -------------------------------------------------------------------------------- /app/api/v1/moderate/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { isValidMetadata } from "@/services/metadata"; 3 | 4 | const MetadataSchema = z.record(z.string(), z.unknown()).refine(isValidMetadata, { 5 | message: 6 | "Metadata keys can't be more than 40 characters or include '[' or ']'. Metadata values must be serializable and can't be more than 500 characters. The total number of keys can't be more than 50.", 7 | }); 8 | 9 | export const ModerateRequestData = z 10 | .object({ 11 | clientId: z.string(), 12 | clientUrl: z.string().url().optional(), 13 | name: z.string(), 14 | entity: z.string(), 15 | content: z.union([ 16 | z.string(), 17 | z.object({ 18 | text: z.string(), 19 | imageUrls: z.array(z.string().url()).optional(), 20 | externalUrls: z.array(z.string().url()).optional(), 21 | }), 22 | ]), 23 | metadata: MetadataSchema.optional(), 24 | user: z 25 | .object({ 26 | clientId: z.string(), 27 | clientUrl: z.string().url().optional(), 28 | stripeAccountId: z.string().optional(), 29 | email: z.string().optional(), 30 | name: z.string().optional(), 31 | username: z.string().optional(), 32 | protected: z.boolean().optional(), 33 | metadata: MetadataSchema.optional(), 34 | }) 35 | .optional(), 36 | passthrough: z.boolean().optional().default(false), 37 | }) 38 | .strict(); 39 | 40 | export type ModerateRequestData = z.infer; 41 | 42 | function isRecord(value: unknown): value is Record { 43 | return typeof value === "object" && value !== null; 44 | } 45 | 46 | export const moderateAdapter = (data: unknown) => { 47 | if (!isRecord(data)) { 48 | return data; 49 | } 50 | const { text, fileUrls, ...rest } = data; 51 | return { content: { text, imageUrls: fileUrls }, ...rest }; 52 | }; 53 | -------------------------------------------------------------------------------- /app/api/v1/records/[recordId]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { and, eq, isNull } from "drizzle-orm"; 3 | 4 | import db from "@/db"; 5 | import * as schema from "@/db/schema"; 6 | import { parseMetadata } from "@/services/metadata"; 7 | import { authenticateRequest } from "@/app/api/auth"; 8 | 9 | export async function GET(req: NextRequest, { params }: { params: Promise<{ recordId: string }> }) { 10 | const [isValid, clerkOrganizationId] = await authenticateRequest(req); 11 | if (!isValid) { 12 | return NextResponse.json({ error: { message: "Invalid API key" } }, { status: 401 }); 13 | } 14 | 15 | const { recordId: id } = await params; 16 | 17 | const record = await db.query.records.findFirst({ 18 | where: and( 19 | eq(schema.records.clerkOrganizationId, clerkOrganizationId), 20 | eq(schema.records.id, id), 21 | isNull(schema.records.deletedAt), 22 | ), 23 | columns: { 24 | id: true, 25 | clientId: true, 26 | clientUrl: true, 27 | name: true, 28 | entity: true, 29 | protected: true, 30 | metadata: true, 31 | createdAt: true, 32 | updatedAt: true, 33 | moderationStatus: true, 34 | moderationStatusCreatedAt: true, 35 | moderationPending: true, 36 | moderationPendingCreatedAt: true, 37 | userRecordId: true, 38 | }, 39 | }); 40 | 41 | if (!record) { 42 | return NextResponse.json({ error: { message: "Record not found" } }, { status: 404 }); 43 | } 44 | 45 | const { userRecordId, metadata, ...rest } = record; 46 | return NextResponse.json({ 47 | data: { 48 | ...rest, 49 | user: userRecordId, 50 | metadata: metadata ? parseMetadata(metadata) : undefined, 51 | }, 52 | }); 53 | } 54 | -------------------------------------------------------------------------------- /app/api/v1/users/[userId]/create_appeal/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const CreateAppealRequestData = z 4 | .object({ 5 | text: z.string(), 6 | }) 7 | .strict(); 8 | 9 | export type CreateAppealRequestData = z.infer; 10 | -------------------------------------------------------------------------------- /app/api/v1/users/[userId]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { and, eq } from "drizzle-orm"; 3 | 4 | import db from "@/db"; 5 | import * as schema from "@/db/schema"; 6 | import { findOrCreateOrganization } from "@/services/organizations"; 7 | import { generateAppealToken } from "@/services/appeals"; 8 | import { getAbsoluteUrl } from "@/lib/url"; 9 | import { authenticateRequest } from "@/app/api/auth"; 10 | 11 | export async function GET(req: NextRequest, { params }: { params: Promise<{ userId: string }> }) { 12 | const [isValid, clerkOrganizationId] = await authenticateRequest(req); 13 | if (!isValid) { 14 | return NextResponse.json({ error: { message: "Invalid API key" } }, { status: 401 }); 15 | } 16 | 17 | const { userId: id } = await params; 18 | 19 | const userRecord = await db.query.userRecords.findFirst({ 20 | where: and(eq(schema.userRecords.clerkOrganizationId, clerkOrganizationId), eq(schema.userRecords.id, id)), 21 | columns: { 22 | id: true, 23 | clientId: true, 24 | clientUrl: true, 25 | email: true, 26 | name: true, 27 | username: true, 28 | protected: true, 29 | metadata: true, 30 | createdAt: true, 31 | updatedAt: true, 32 | actionStatus: true, 33 | actionStatusCreatedAt: true, 34 | }, 35 | }); 36 | 37 | if (!userRecord) { 38 | return NextResponse.json({ error: { message: "User not found" } }, { status: 404 }); 39 | } 40 | 41 | const organization = await findOrCreateOrganization(clerkOrganizationId); 42 | 43 | const appealUrl = 44 | organization.appealsEnabled && userRecord.actionStatus === "Suspended" 45 | ? getAbsoluteUrl(`/appeal?token=${generateAppealToken(userRecord.id)}`) 46 | : null; 47 | 48 | return NextResponse.json({ data: { ...userRecord, appealUrl } }); 49 | } 50 | -------------------------------------------------------------------------------- /app/api/webhooks/clerk/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { Webhook } from "svix"; 3 | import { WebhookEvent } from "@clerk/nextjs/server"; 4 | import { inngest } from "@/inngest/client"; 5 | import { env } from "@/lib/env"; 6 | 7 | export async function POST(req: NextRequest) { 8 | const svix_id = req.headers.get("svix-id"); 9 | const svix_timestamp = req.headers.get("svix-timestamp"); 10 | const svix_signature = req.headers.get("svix-signature"); 11 | 12 | if (!svix_id || !svix_timestamp || !svix_signature) { 13 | return new NextResponse("Error: Missing svix headers", { status: 400 }); 14 | } 15 | 16 | if (!env.CLERK_WEBHOOK_SECRET) { 17 | return new NextResponse("Error: Missing CLERK_WEBHOOK_SECRET", { status: 500 }); 18 | } 19 | 20 | const payload = await req.json(); 21 | const body = JSON.stringify(payload); 22 | 23 | const wh = new Webhook(env.CLERK_WEBHOOK_SECRET); 24 | 25 | let evt: WebhookEvent; 26 | 27 | try { 28 | evt = wh.verify(body, { 29 | "svix-id": svix_id, 30 | "svix-timestamp": svix_timestamp, 31 | "svix-signature": svix_signature, 32 | }) as WebhookEvent; 33 | } catch (err) { 34 | console.error("Error verifying webhook:", err); 35 | return new NextResponse("Error verifying webhook", { status: 400 }); 36 | } 37 | 38 | const eventType = evt.type; 39 | 40 | if (eventType === "user.created") { 41 | await inngest.send({ 42 | name: "clerk/user.created", 43 | data: evt.data, 44 | }); 45 | } 46 | 47 | return new NextResponse("Webhook received", { status: 200 }); 48 | } 49 | -------------------------------------------------------------------------------- /app/appeal/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { createSafeActionClient } from "next-safe-action"; 4 | import { z } from "zod"; 5 | import { submitAppealSchema } from "./validation"; 6 | import { createAppeal, validateAppealToken } from "@/services/appeals"; 7 | import { revalidatePath } from "next/cache"; 8 | 9 | const appealActionClient = createSafeActionClient(); 10 | 11 | export const submitAppeal = appealActionClient 12 | .schema(submitAppealSchema) 13 | .bindArgsSchemas<[token: z.ZodString]>([z.string()]) 14 | .action(async ({ parsedInput: { text }, bindArgsParsedInputs: [token] }) => { 15 | const [isValid, userRecordId] = await validateAppealToken(token); 16 | if (!isValid) { 17 | throw new Error("Invalid appeal token"); 18 | } 19 | const appeal = await createAppeal({ userRecordId, text }); 20 | revalidatePath("/appeal"); 21 | return { appeal }; 22 | }); 23 | -------------------------------------------------------------------------------- /app/appeal/validation.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const submitAppealSchema = z.object({ 4 | text: z.string().min(1, "Appeal text is required").max(1000, "Appeal text must be less than 1000 characters"), 5 | }); 6 | -------------------------------------------------------------------------------- /app/count-lazy.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import dynamic from "next/dynamic"; 4 | 5 | // The ssr: false option only works from a client component, 6 | // hence the need for this wrapper 7 | // https://nextjs.org/docs/app/building-your-application/optimizing/lazy-loading#skipping-ssr 8 | const Count = dynamic(() => import("./count").then((mod) => mod.Count), { 9 | ssr: false, 10 | loading: () => ..., 11 | }); 12 | 13 | export function CountLazy({ count, countAt, ratePerHour }: { count: number; countAt: Date; ratePerHour: number }) { 14 | return ; 15 | } 16 | -------------------------------------------------------------------------------- /app/count.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import NumberFlow, { continuous } from "@number-flow/react"; 4 | import { useEffect, useState } from "react"; 5 | 6 | const getTotal = (count: number, countAt: Date, ratePerHour: number) => 7 | Math.floor(count + ((new Date().getTime() - countAt.getTime()) / 1000 / 60 / 60) * ratePerHour); 8 | 9 | export function Count({ count, countAt, ratePerHour }: { count: number; countAt: Date; ratePerHour: number }) { 10 | const [total, setTotal] = useState(getTotal(count, countAt, ratePerHour)); 11 | 12 | useEffect(() => { 13 | const interval = setInterval(() => { 14 | setTotal(getTotal(count, countAt, ratePerHour)); 15 | }, 1000); 16 | return () => clearInterval(interval); 17 | }, [count, countAt, ratePerHour]); 18 | 19 | return ; 20 | } 21 | -------------------------------------------------------------------------------- /app/dashboard-moderations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antiwork/iffy/2fa2c7d80cc0d8f2d8d1cb4102ee90336ec2c814/app/dashboard-moderations.png -------------------------------------------------------------------------------- /app/dashboard-rules.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antiwork/iffy/2fa2c7d80cc0d8f2d8d1cb4102ee90336ec2c814/app/dashboard-rules.png -------------------------------------------------------------------------------- /app/dashboard-users.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antiwork/iffy/2fa2c7d80cc0d8f2d8d1cb4102ee90336ec2c814/app/dashboard-users.png -------------------------------------------------------------------------------- /app/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antiwork/iffy/2fa2c7d80cc0d8f2d8d1cb4102ee90336ec2c814/app/dashboard.png -------------------------------------------------------------------------------- /app/dashboard/@sheet/(.)records/[recordId]/moderations/[moderationId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { authWithOrgSubscription } from "@/app/dashboard/auth"; 2 | import { ModerationDetail } from "@/app/dashboard/records/[recordId]/moderations/[moderationId]/moderation"; 3 | import { notFound } from "next/navigation"; 4 | import { RouterSheet } from "@/components/router-sheet"; 5 | import { Metadata } from "next"; 6 | import db from "@/db"; 7 | import * as schema from "@/db/schema"; 8 | import { and, eq } from "drizzle-orm"; 9 | import { formatRecord } from "@/lib/record"; 10 | 11 | export async function generateMetadata({ params }: { params: Promise<{ moderationId: string }> }): Promise { 12 | const { orgId } = await authWithOrgSubscription(); 13 | 14 | const id = (await params).moderationId; 15 | 16 | const moderation = await db.query.moderations.findFirst({ 17 | where: and(eq(schema.moderations.clerkOrganizationId, orgId), eq(schema.moderations.id, id)), 18 | with: { 19 | record: true, 20 | }, 21 | }); 22 | 23 | if (!moderation) { 24 | return notFound(); 25 | } 26 | 27 | return { 28 | title: `${formatRecord(moderation.record)} | Moderation | Iffy`, 29 | }; 30 | } 31 | 32 | export default async function Page({ params }: { params: Promise<{ moderationId: string }> }) { 33 | const { orgId } = await authWithOrgSubscription(); 34 | 35 | const id = (await params).moderationId; 36 | 37 | return ( 38 | 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /app/dashboard/@sheet/(.)records/[recordId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | import { RecordDetail } from "@/app/dashboard/records/[recordId]/record"; 3 | import { RouterSheet } from "@/components/router-sheet"; 4 | import db from "@/db"; 5 | import * as schema from "@/db/schema"; 6 | import { and, eq } from "drizzle-orm"; 7 | import { notFound } from "next/navigation"; 8 | import { Metadata } from "next"; 9 | import { authWithOrgSubscription } from "@/app/dashboard/auth"; 10 | import { formatRecord } from "@/lib/record"; 11 | 12 | export async function generateMetadata({ params }: { params: Promise<{ recordId: string }> }): Promise { 13 | const { orgId } = await authWithOrgSubscription(); 14 | 15 | const id = (await params).recordId; 16 | 17 | const record = await db.query.records.findFirst({ 18 | where: and(eq(schema.records.clerkOrganizationId, orgId), eq(schema.records.id, id)), 19 | }); 20 | 21 | if (!record) { 22 | return notFound(); 23 | } 24 | 25 | return { 26 | title: `Record ${formatRecord(record)} (${record.entity}) | Iffy`, 27 | }; 28 | } 29 | 30 | export default async function Page({ params }: { params: Promise<{ recordId: string }> }) { 31 | const { orgId } = await authWithOrgSubscription(); 32 | 33 | const id = (await params).recordId; 34 | return ( 35 | 36 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /app/dashboard/@sheet/(.)users/[userId]/actions/[actionId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { authWithOrgSubscription } from "@/app/dashboard/auth"; 2 | import { UserActionDetail } from "@/app/dashboard/users/[userId]/actions/[actionId]/user-action"; 3 | import { redirect, notFound } from "next/navigation"; 4 | import { RouterSheet } from "@/components/router-sheet"; 5 | import { Metadata } from "next"; 6 | import db from "@/db"; 7 | import * as schema from "@/db/schema"; 8 | import { and, eq } from "drizzle-orm"; 9 | import { formatUserRecord } from "@/lib/user-record"; 10 | 11 | export async function generateMetadata({ params }: { params: Promise<{ actionId: string }> }): Promise { 12 | const { orgId } = await authWithOrgSubscription(); 13 | 14 | const id = (await params).actionId; 15 | 16 | const userAction = await db.query.userActions.findFirst({ 17 | where: and(eq(schema.userActions.clerkOrganizationId, orgId), eq(schema.userActions.id, id)), 18 | with: { 19 | userRecord: true, 20 | }, 21 | }); 22 | 23 | if (!userAction) { 24 | return notFound(); 25 | } 26 | 27 | return { 28 | title: `${formatUserRecord(userAction.userRecord)} | User action | Iffy`, 29 | }; 30 | } 31 | 32 | export default async function Page({ params }: { params: Promise<{ actionId: string }> }) { 33 | const { orgId } = await authWithOrgSubscription(); 34 | 35 | const id = (await params).actionId; 36 | 37 | return ( 38 | 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /app/dashboard/@sheet/(.)users/[userId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { authWithOrgSubscription } from "@/app/dashboard/auth"; 2 | import { UserRecordDetail } from "@/app/dashboard/users/[userId]/user-record"; 3 | import { notFound, redirect } from "next/navigation"; 4 | import { RouterSheet } from "@/components/router-sheet"; 5 | import db from "@/db"; 6 | import { formatUserRecordCompact } from "@/lib/user-record"; 7 | import * as schema from "@/db/schema"; 8 | import { and, eq } from "drizzle-orm"; 9 | import { Metadata } from "next"; 10 | 11 | export async function generateMetadata({ params }: { params: Promise<{ userId: string }> }): Promise { 12 | const { orgId } = await authWithOrgSubscription(); 13 | 14 | const id = (await params).userId; 15 | 16 | const userRecord = await db.query.userRecords.findFirst({ 17 | where: and(eq(schema.userRecords.clerkOrganizationId, orgId), eq(schema.userRecords.id, id)), 18 | }); 19 | 20 | if (!userRecord) { 21 | return notFound(); 22 | } 23 | 24 | return { 25 | title: `User ${formatUserRecordCompact(userRecord)} | Iffy`, 26 | }; 27 | } 28 | 29 | export default async function Page({ params }: { params: Promise<{ userId: string }> }) { 30 | const { orgId } = await authWithOrgSubscription(); 31 | 32 | const id = (await params).userId; 33 | return ( 34 | 35 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /app/dashboard/@sheet/[...catchAll]/page.tsx: -------------------------------------------------------------------------------- 1 | export default function Page() { 2 | return null; 3 | } 4 | -------------------------------------------------------------------------------- /app/dashboard/@sheet/default.tsx: -------------------------------------------------------------------------------- 1 | export default function Default() { 2 | return null; 3 | } 4 | -------------------------------------------------------------------------------- /app/dashboard/@sheet/page.tsx: -------------------------------------------------------------------------------- 1 | export default function Page() { 2 | return null; 3 | } 4 | -------------------------------------------------------------------------------- /app/dashboard/analytics/hourly-section.tsx: -------------------------------------------------------------------------------- 1 | import * as schema from "@/db/schema"; 2 | import db from "@/db"; 3 | import { eq } from "drizzle-orm"; 4 | import { HourlyAnalyticsChart } from "./hourly-analytics-chart"; 5 | 6 | type HourlyAnalyticsChartData = Omit; 7 | 8 | export async function HourlySection({ orgId, byRule = false }: { orgId: string; byRule?: boolean }) { 9 | const stats = await db 10 | .select({ 11 | time: schema.moderationsAnalyticsHourly.time, 12 | moderations: schema.moderationsAnalyticsHourly.moderations, 13 | flagged: schema.moderationsAnalyticsHourly.flagged, 14 | flaggedByRule: schema.moderationsAnalyticsHourly.flaggedByRule, 15 | }) 16 | .from(schema.moderationsAnalyticsHourly) 17 | .where(eq(schema.moderationsAnalyticsHourly.clerkOrganizationId, orgId)); 18 | 19 | // Builds a 24-hour timeline of moderation stats, filling gaps with zeros 20 | const result = []; 21 | const now = new Date(); 22 | for (let i = 23; i >= 0; i--) { 23 | const hour = new Date(now.getTime()); 24 | hour.setHours(now.getHours() - i, 0, 0, 0); 25 | const stat = stats.find((s) => { 26 | const sTime = new Date(s.time); 27 | return ( 28 | sTime.getHours() === hour.getHours() && 29 | sTime.getDate() === hour.getDate() && 30 | sTime.getMonth() === hour.getMonth() && 31 | sTime.getFullYear() === hour.getFullYear() 32 | ); 33 | }); 34 | if (stat) { 35 | const { flaggedByRule, ...rest } = stat; 36 | result.push({ 37 | ...rest, 38 | flaggedByRule: { 39 | ...flaggedByRule, 40 | other: { 41 | count: Math.max(0, rest.flagged - Object.values(flaggedByRule).reduce((acc, curr) => acc + curr.count, 0)), 42 | name: "Other", 43 | description: null, 44 | }, 45 | }, 46 | }); 47 | } else { 48 | const empty: HourlyAnalyticsChartData = { 49 | time: hour, 50 | moderations: 0, 51 | flagged: 0, 52 | flaggedByRule: {}, 53 | }; 54 | result.push(empty); 55 | } 56 | } 57 | 58 | return ; 59 | } 60 | -------------------------------------------------------------------------------- /app/dashboard/analytics/skeletons.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 2 | import { Skeleton } from "@/components/ui/skeleton"; 3 | 4 | export function TotalsSkeleton() { 5 | return ( 6 | <> 7 | 8 | 9 | Total moderations 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | Total flagged 18 | 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | } 26 | 27 | export function ChartSkeleton() { 28 | return ( 29 | 30 | 31 |
32 | Moderations 33 | 34 |
35 |
36 |
37 | 38 | 39 |
40 |
41 | 42 | 43 |
44 |
45 |
46 | 47 | 48 | 49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /app/dashboard/analytics/totals-cards.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 2 | 3 | export interface TotalsCardsProps { 4 | totals: { 5 | moderations: number; 6 | flagged: number; 7 | }; 8 | } 9 | 10 | export function TotalsCards({ totals }: TotalsCardsProps) { 11 | return ( 12 | <> 13 | 14 | 15 | Total moderations 16 | 17 | 18 |
{totals.moderations.toLocaleString()}
19 |
20 |
21 | 22 | 23 | Total flagged 24 | 25 | 26 |
{totals.flagged.toLocaleString()}
27 |
28 |
29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /app/dashboard/auth.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@/lib/env"; 2 | import { findSubscription, isActiveSubscription } from "@/services/stripe/subscriptions"; 3 | import { auth } from "@clerk/nextjs/server"; 4 | import { redirect } from "next/navigation"; 5 | 6 | export async function authWithOrgSubscription() { 7 | const { orgId, userId, ...data } = await auth(); 8 | 9 | if (!userId) { 10 | redirect("/"); 11 | } 12 | 13 | if (!orgId) { 14 | redirect("/dashboard"); 15 | } 16 | 17 | if (!env.ENABLE_BILLING) { 18 | return { orgId, userId, ...data }; 19 | } 20 | 21 | const subscription = await findSubscription(orgId); 22 | if (!subscription) { 23 | redirect("/dashboard/subscription"); 24 | } 25 | 26 | if (!isActiveSubscription(subscription)) { 27 | redirect("/dashboard/subscription"); 28 | } 29 | 30 | return { orgId, userId, ...data, subscription }; 31 | } 32 | 33 | export async function authWithOrg() { 34 | const { orgId, userId, ...data } = await auth(); 35 | 36 | if (!userId) { 37 | redirect("/"); 38 | } 39 | 40 | if (!orgId) { 41 | redirect("/dashboard"); 42 | } 43 | 44 | return { orgId, userId, ...data }; 45 | } 46 | -------------------------------------------------------------------------------- /app/dashboard/developer/key-deletion-dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { 4 | AlertDialog, 5 | AlertDialogAction, 6 | AlertDialogCancel, 7 | AlertDialogContent, 8 | AlertDialogDescription, 9 | AlertDialogFooter, 10 | AlertDialogHeader, 11 | AlertDialogTitle, 12 | AlertDialogTrigger, 13 | } from "@/components/ui/alert-dialog"; 14 | 15 | import { Trash2 } from "lucide-react"; 16 | import { Key } from "./settings"; 17 | 18 | export const KeyDeletionDialog = ({ value, onDelete }: { value: Key; onDelete: (id: string) => void }) => { 19 | return ( 20 | 21 | 22 |
23 | 24 | Delete 25 |
26 |
27 | 28 | 29 | Delete API key 30 | 31 | Are you sure you want to delete the API key "{value.name}"? This action cannot be undone. 32 | 33 | 34 | 35 | Cancel 36 | onDelete(value.id)}>Delete 37 | 38 | 39 |
40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /app/dashboard/developer/page.tsx: -------------------------------------------------------------------------------- 1 | import { getApiKeys } from "@/services/api-keys"; 2 | import { Settings } from "./settings"; 3 | import { findOrCreateOrganization } from "@/services/organizations"; 4 | import { Metadata } from "next"; 5 | 6 | export const metadata: Metadata = { 7 | title: "Developer Settings | Iffy", 8 | }; 9 | 10 | import db from "@/db"; 11 | import * as schema from "@/db/schema"; 12 | import { authWithOrgSubscription } from "@/app/dashboard/auth"; 13 | import { redirect } from "next/navigation"; 14 | import { eq } from "drizzle-orm"; 15 | import { decrypt } from "@/services/encrypt"; 16 | 17 | export default async function DeveloperPage() { 18 | const { orgId } = await authWithOrgSubscription(); 19 | 20 | const keys = await getApiKeys({ clerkOrganizationId: orgId }); 21 | const webhookEndpoint = await db.query.webhookEndpoints.findFirst({ 22 | where: eq(schema.webhookEndpoints.clerkOrganizationId, orgId), 23 | }); 24 | if (webhookEndpoint) { 25 | webhookEndpoint.secret = decrypt(webhookEndpoint.secret); 26 | } 27 | 28 | const organization = await findOrCreateOrganization(orgId); 29 | 30 | return ( 31 |
32 | 39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /app/dashboard/emails/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { z } from "zod"; 4 | import { actionClient } from "@/lib/action-client"; 5 | import { revalidatePath } from "next/cache"; 6 | import db from "@/db"; 7 | import * as schema from "@/db/schema"; 8 | import { updateEmailTemplateSchema } from "./schema"; 9 | import { validateContent } from "@/emails/render"; 10 | 11 | export const updateEmailTemplate = actionClient 12 | .schema(updateEmailTemplateSchema) 13 | .bindArgsSchemas([z.enum(schema.emailTemplateType.enumValues)]) 14 | .action( 15 | async ({ parsedInput: { subject, heading, body }, bindArgsParsedInputs: [type], ctx: { clerkOrganizationId } }) => { 16 | validateContent({ subject, heading, body }); 17 | 18 | const [emailTemplate] = await db 19 | .insert(schema.emailTemplates) 20 | .values({ 21 | clerkOrganizationId, 22 | type, 23 | content: { subject, heading, body }, 24 | }) 25 | .onConflictDoUpdate({ 26 | target: [schema.emailTemplates.clerkOrganizationId, schema.emailTemplates.type], 27 | set: { 28 | content: { subject, heading, body }, 29 | }, 30 | }) 31 | .returning(); 32 | 33 | revalidatePath("/dashboard/emails"); 34 | return emailTemplate; 35 | }, 36 | ); 37 | -------------------------------------------------------------------------------- /app/dashboard/emails/preview.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import { useEffect, useLayoutEffect, useRef, useState } from "react"; 5 | 6 | const INITIAL_HEIGHT = 800; 7 | 8 | export const Preview = ({ html, className, ...props }: { html: string } & React.HTMLAttributes) => { 9 | const ref = useRef(null); 10 | const [height, setHeight] = useState(INITIAL_HEIGHT + "px"); 11 | 12 | const resize = () => { 13 | if (ref.current?.contentWindow) { 14 | const height = ref.current.contentWindow.document.body.scrollHeight; 15 | if (height > 0) { 16 | setHeight(height + "px"); 17 | } 18 | } 19 | }; 20 | 21 | useLayoutEffect(() => { 22 | resize(); 23 | }, [html]); 24 | 25 | return ( 26 |