diff --git a/.env.example b/.env.example index 58f9385..62c0380 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,16 @@ GOOGLE_CLIENT_SECRET= # Your @umich.edu. Local only. LOCAL_ADMIN_EMAILS=you@umich.edu + +# AWS S3 (dev bucket for local + Vercel Preview; prod bucket later) +# Bucket CORS must allow PUT/GET from your dev origin (e.g. http://localhost:3000). +AWS_REGION= +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +S3_BUCKET= + +# AWS SES (application confirmation). Reuses AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY. +# SES_REGION falls back to AWS_REGION. SES_REPLY_TO is optional. +SES_REGION=us-east-2 +SES_FROM_EMAIL=noreply@ktpmichigan.com +SES_REPLY_TO= \ No newline at end of file diff --git a/docs/database-diagram.md b/docs/database-diagram.md new file mode 100644 index 0000000..3cb5a80 --- /dev/null +++ b/docs/database-diagram.md @@ -0,0 +1,149 @@ +# Database diagram (DBML) + +Paste into [dbdiagram.io](https://dbdiagram.io). + +```dbml +// Public Postgres schema for the rush application portal. +// auth.users is Supabase Auth. applications.user_id stores that UUID with no FK. + +Table admins { + email varchar [primary key, note: 'Extra web allowlist; e-board is also admin via assignments later'] + created_at timestamptz [not null, default: `now()`] +} + +Table brothers { + id uuid [pk] + first_name varchar + last_name varchar + umich_email varchar [note: 'nullable; unique when set; /portal login'] + contact_email varchar + linkedin_url varchar + photo_filename varchar [note: 'Dummy filename until S3'] + status varchar [not null, default: 'active', note: 'active | alumni'] + pledge_class varchar + created_at timestamptz [not null, default: `now()`] + updated_at timestamptz [not null, default: `now()`] +} + +Table rush_cycles { + id uuid [primary key, default: `gen_random_uuid()`] + name varchar [not null] + opens_at timestamptz [not null] + closes_at timestamptz [not null] + intro_markdown text [note: 'Welcome copy on /apply'] + closed_markdown text [note: 'Shown on /apply after close'] + public_blurb text [note: 'Copy on /rush'] + interest_form_url varchar + youtube_url varchar + calendar_url varchar + hear_about_options varchar[] [not null, default: `{}`] + is_active boolean [not null, default: false, note: 'At most one true (partial unique index)'] + created_at timestamptz [not null, default: `now()`] + updated_at timestamptz [not null, default: `now()`] + + Note: 'One live cycle on the site. Owns /rush, schedule, and application questions.' +} + +Table rush_events { + id uuid [primary key, default: `gen_random_uuid()`] + cycle_id uuid [not null] + title varchar [not null] + datetime varchar [not null] + location varchar [not null] + description text + button_label varchar + button_url varchar + order_index integer [not null, default: 0] + created_at timestamptz [not null, default: `now()`] + updated_at timestamptz [not null, default: `now()`] + + indexes { + (cycle_id, order_index) [name: 'rush_events_cycle_id_idx'] + } +} + +Table cycle_questions { + id uuid [primary key, default: `gen_random_uuid()`] + cycle_id uuid [not null] + prompt text [not null] + help_text text + max_words integer [not null] + sort_order integer [not null, default: 0] + required boolean [not null, default: true] + + indexes { + (cycle_id, sort_order) [name: 'cycle_questions_cycle_id_idx'] + } +} + +Table applications { + id uuid [primary key, default: `gen_random_uuid()`] + cycle_id uuid [not null] + user_id uuid [not null, note: 'auth.users.id; no FK'] + email varchar [not null] + status varchar [not null, default: 'draft', note: 'draft | submitted'] + submitted_at timestamptz + first_name varchar + last_name varchar + preferred_name varchar + pronouns varchar + phone varchar + majors varchar + minors varchar + graduation_year integer + gpa numeric + semesters_remaining integer + other_professional_fraternity boolean + campus_activities text + hear_about varchar[] + hear_about_other varchar + anything_else text + rush_feedback text + created_at timestamptz [not null, default: `now()`] + updated_at timestamptz [not null, default: `now()`] + + indexes { + (cycle_id, user_id) [unique, name: 'applications_cycle_user_unique'] + user_id [name: 'applications_user_id_idx'] + (cycle_id, status) [name: 'applications_cycle_status_idx'] + } +} + +Table application_answers { + id uuid [primary key, default: `gen_random_uuid()`] + application_id uuid [not null] + question_id uuid [not null] + body text + + indexes { + (application_id, question_id) [unique, name: 'application_answers_app_question_unique'] + } +} + +Table application_files { + id uuid [primary key, default: `gen_random_uuid()`] + application_id uuid [not null] + slot varchar [not null, note: 'photo | transcript | resume | resume_anonymized | life_app_screenshot'] + s3_key varchar [not null] + mime_type varchar + size_bytes integer + original_filename varchar + created_at timestamptz [not null, default: `now()`] + + indexes { + (application_id, slot) [unique, name: 'application_files_app_slot_unique'] + } +} + +Table auth_users [note: 'Supabase Auth; not a public table we migrate'] { + id uuid [primary key] + email varchar +} + +Ref: rush_events.cycle_id > rush_cycles.id [delete: cascade] +Ref: cycle_questions.cycle_id > rush_cycles.id [delete: cascade] +Ref: applications.cycle_id > rush_cycles.id [delete: restrict] +Ref: application_answers.application_id > applications.id [delete: cascade] +Ref: application_answers.question_id > cycle_questions.id [delete: restrict] +Ref: application_files.application_id > applications.id [delete: cascade] +``` diff --git a/package-lock.json b/package-lock.json index db71d64..7ebb244 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "temp-next", "version": "0.1.0", "dependencies": { + "@aws-sdk/client-s3": "^3.1116.0", + "@aws-sdk/client-ses": "^3.1118.0", + "@aws-sdk/s3-request-presigner": "^3.1116.0", "@fortawesome/fontawesome-svg-core": "^7.0.0", "@fortawesome/free-solid-svg-icons": "^7.0.0", "@fortawesome/react-fontawesome": "^0.2.3", @@ -26,7 +29,8 @@ "react-scroll": "^1.9.3", "react-typed": "^2.0.12", "server-only": "^0.0.1", - "zod": "^4.4.3" + "zod": "^4.4.3", + "zustand": "^5.0.15" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -34,6 +38,7 @@ "@types/aos": "^3.0.7", "@types/leaflet": "^1.9.20", "@types/node": "^20", + "@types/papaparse": "^5.5.2", "@types/react": "^19", "@types/react-dom": "^19", "@types/react-scroll": "^1.8.10", @@ -60,6 +65,350 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@aws-sdk/checksums": { + "version": "3.1000.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.29.tgz", + "integrity": "sha512-Dtu0gr4dnATZAPwEYbpCsG+MpLM7OAliy2gTepEFQwl1vZ6DL3QMH2FveMa3HLvPsOdhJsPRB3KtxVhph9T75A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1116.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1116.0.tgz", + "integrity": "sha512-UKRl9qSVW0rZpvSOauQNpYAy8+ONBAVYnpfKVtCyOF+FZVT1tl6MunYuHvuarCrroD2/YJs+tHTALYNgAlec3Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1000.29", + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-node": "^3.972.81", + "@aws-sdk/middleware-sdk-s3": "^3.972.75", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-ses": { + "version": "3.1118.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-ses/-/client-ses-3.1118.0.tgz", + "integrity": "sha512-qamd2FQKRgdj/2ipytnuvHzVzwB49PVbYoC77+Ibf/jlJu9HOaNEvMjKtnDOxo0WmIe8bCZ5C4CVSS7MipmK/w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-node": "^3.972.81", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.977.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.9.tgz", + "integrity": "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@aws-sdk/xml-builder": "^3.972.40", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.33.3", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.70.tgz", + "integrity": "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.72.tgz", + "integrity": "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.15.tgz", + "integrity": "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-login": "^3.972.77", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.77", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.77.tgz", + "integrity": "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.81", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.81.tgz", + "integrity": "sha512-Rml+WitoFvXmv6JZ18U/xGdGDGGvB/mOin0ya0lTnTrdC0Z1lrVxTYh7iNklZBcvcRMrs4DoEf6xy1KWyrLQQw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-ini": "^3.973.15", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.70.tgz", + "integrity": "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.14.tgz", + "integrity": "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/token-providers": "3.1116.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.76.tgz", + "integrity": "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.75", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.75.tgz", + "integrity": "sha512-wMIsNumRVKaNMKhvU/s9VrdEwE8S6gSzXp4RygFG5BEMnGkkXf8cjh8zf7cKJBpUDpqTWqwbz5isEgp9rH6Lng==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.44.tgz", + "integrity": "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.1116.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1116.0.tgz", + "integrity": "sha512-WwaaVpvrZyML5L8SNY7sUGsYlMeCHzQ/8A/ms7dz/7sfYRvPn+qf0OasUWk+zuT8qGwjsYhILD0WVtlqUU08pQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz", + "integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1116.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1116.0.tgz", + "integrity": "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.5.tgz", + "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.40", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz", + "integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -2375,6 +2724,87 @@ "dev": true, "license": "MIT" }, + "node_modules/@smithy/core": { + "version": "3.33.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.3.tgz", + "integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.2.tgz", + "integrity": "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.11.3.tgz", + "integrity": "sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.3.tgz", + "integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.2.tgz", + "integrity": "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@supabase/auth-js": { "version": "2.81.1", "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.81.1.tgz", @@ -2951,6 +3381,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/papaparse": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz", + "integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/phoenix": { "version": "1.6.6", "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", @@ -2961,7 +3401,7 @@ "version": "19.1.11", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.11.tgz", "integrity": "sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -4079,6 +4519,12 @@ "node": "*" } }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -4342,7 +4788,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -10187,6 +10633,35 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zustand": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index b0e1a76..956de7c 100644 --- a/package.json +++ b/package.json @@ -11,11 +11,15 @@ "db:generate": "drizzle-kit generate", "db:studio": "drizzle-kit studio", "db:seed-admins": "node scripts/seed-local-admins.mjs", + "db:seed-apps": "node scripts/seed-fake-applications.mjs", "images:optimize:home": "node scripts/optimize-images.mjs --preset home", "images:optimize:about": "node scripts/optimize-images.mjs --preset about", "images:optimize:members": "node scripts/optimize-images.mjs --preset member" }, "dependencies": { + "@aws-sdk/client-s3": "^3.1116.0", + "@aws-sdk/client-ses": "^3.1118.0", + "@aws-sdk/s3-request-presigner": "^3.1116.0", "@fortawesome/fontawesome-svg-core": "^7.0.0", "@fortawesome/free-solid-svg-icons": "^7.0.0", "@fortawesome/react-fontawesome": "^0.2.3", @@ -34,7 +38,8 @@ "react-scroll": "^1.9.3", "react-typed": "^2.0.12", "server-only": "^0.0.1", - "zod": "^4.4.3" + "zod": "^4.4.3", + "zustand": "^5.0.15" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -42,6 +47,7 @@ "@types/aos": "^3.0.7", "@types/leaflet": "^1.9.20", "@types/node": "^20", + "@types/papaparse": "^5.5.2", "@types/react": "^19", "@types/react-dom": "^19", "@types/react-scroll": "^1.8.10", diff --git a/public/emails/f26-rush/application-received.html b/public/emails/f26-rush/application-received.html new file mode 100644 index 0000000..2f214b4 --- /dev/null +++ b/public/emails/f26-rush/application-received.html @@ -0,0 +1,86 @@ + + + + + + KTP Application Received + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Kappa Theta Pi Alpha Chapter +
+

Hi there,

+ +

Thank you for applying to Kappa Theta Pi at the University of Michigan!

+ +

Your application for Kappa Theta Pi Fall 2026 Rush Application has been successfully received.

+ +

You may continue editing your responses until the application deadline.

+ +

View or update your application here:

+
+ View Application +
+

If you have any questions about your application or the rush process, please email ktp-board@umich.edu.

+ +

+ Best,
+ Kappa Theta Pi
+ University of Michigan +

+
+

Visit ktpmichigan.com and follow @ktpumich to learn more.

+
+ Kappa Theta Pi +
+

+ Having trouble viewing this email? + View in browser +

+
+
+ + diff --git a/public/images/beep-bop.svg b/public/images/beep-bop.svg new file mode 100644 index 0000000..0e419da --- /dev/null +++ b/public/images/beep-bop.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/home/phone_frame_3.png b/public/images/home/phone_frame_3.png new file mode 100644 index 0000000..a34581c Binary files /dev/null and b/public/images/home/phone_frame_3.png differ diff --git a/public/images/home/phone_frame_4.png b/public/images/home/phone_frame_4.png new file mode 100644 index 0000000..d002c0a Binary files /dev/null and b/public/images/home/phone_frame_4.png differ diff --git a/scripts/seed-fake-applications.mjs b/scripts/seed-fake-applications.mjs new file mode 100644 index 0000000..6b5769d --- /dev/null +++ b/scripts/seed-fake-applications.mjs @@ -0,0 +1,359 @@ +/** + * Seed fake submitted applications for local review testing. + * + * Usage: + * npm run db:seed-apps + * npm run db:seed-apps -- --count 12 + * npm run db:seed-apps -- --cycle "Fall 2026 IN TEST" + * + * Requires DATABASE_URL in .env.local. Safe to re-run — skips seed emails + * already present on the target cycle. + */ + +import { randomUUID } from 'crypto' +import nextEnv from '@next/env' +import postgres from 'postgres' + +const { loadEnvConfig } = nextEnv +loadEnvConfig(process.cwd()) + +const args = process.argv.slice(2) +const countArg = args.find((arg, i) => args[i - 1] === '--count') +const cycleArg = args.find((arg, i) => args[i - 1] === '--cycle') +const count = Math.max(1, Math.min(50, Number(countArg ?? 16) || 16)) + +if (!process.env.DATABASE_URL) { + console.error('DATABASE_URL is not set.') + process.exit(1) +} + +const sql = postgres(process.env.DATABASE_URL, { prepare: false }) + +const APPLICANTS = [ + { + first: 'Alex', + last: 'Chen', + preferred: null, + majors: 'Computer Science', + activities: 'MRacing, Michigan Hackers', + }, + { + first: 'Jordan', + last: 'Patel', + preferred: 'Jo', + majors: 'Data Science, Statistics', + activities: 'KTP interest event, WOLV TV', + }, + { + first: 'Sam', + last: 'Nguyen', + preferred: null, + majors: 'Informatics', + activities: 'UX Club, Design at Michigan', + }, + { + first: 'Riley', + last: 'Johnson', + preferred: null, + majors: 'Computer Engineering', + activities: 'Engineering Council, Solar Car', + }, + { + first: 'Casey', + last: 'Kim', + preferred: 'Casey', + majors: 'Business Administration, CS minor', + activities: 'Entrepreneurship club, consulting group', + }, + { + first: 'Morgan', + last: 'Williams', + preferred: null, + majors: 'Mathematics', + activities: 'Putnam prep, tutoring', + }, + { + first: 'Taylor', + last: 'Brown', + preferred: null, + majors: 'Information Analysis', + activities: 'Campus recycling, IM sports', + }, + { + first: 'Avery', + last: 'Martinez', + preferred: 'Ave', + majors: 'Computer Science, Economics', + activities: 'Investment club, app dev side projects', + }, + { + first: 'Quinn', + last: 'Lee', + preferred: null, + majors: 'Electrical Engineering', + activities: 'IEEE, robotics lab', + }, + { + first: 'Drew', + last: 'Singh', + preferred: null, + majors: 'Computer Science', + activities: 'Open-source contributions, hackathons', + }, + { + first: 'Jamie', + last: 'Okafor', + preferred: 'Jamie', + majors: 'SI (UX Design)', + activities: 'Design for America, photography', + }, + { + first: 'Blake', + last: 'Foster', + preferred: null, + majors: 'Computer Science', + activities: 'Teaching assistant, peer mentor', + }, + { + first: 'Cameron', + last: 'Wright', + preferred: null, + majors: 'Cognitive Science', + activities: 'Research lab, debate', + }, + { + first: 'Harper', + last: 'Diaz', + preferred: 'Harps', + majors: 'Computer Science, Spanish', + activities: 'Language exchange, hackathons', + }, + { + first: 'Reese', + last: 'Nguyen', + preferred: null, + majors: 'Data Science', + activities: 'Analytics club, intramural soccer', + }, + { + first: 'Parker', + last: 'Brooks', + preferred: null, + majors: 'Computer Engineering', + activities: 'MHacks, maker space', + }, + { + first: 'Skyler', + last: 'Adams', + preferred: 'Sky', + majors: 'Business, CS minor', + activities: 'Startups club, volunteer tutoring', + }, +] + +const ESSAY_1 = + 'I would build a campus study-match app that pairs students by course and study style. As a first-gen student, finding study groups was hard; this product reflects my value of accessible community.' +const ESSAY_2 = + 'A memory that stayed with me was fixing the projector before my high school club presentation. I learned to stay calm under pressure, which is how I approach team projects today.' +const ESSAY_3 = + 'Technology — KTP would help me grow technical depth while giving back through mentorship.' + +function cycleSlug(name) { + return ( + name + .replace(/\s*\(local\)\s*/gi, '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'cycle' + ) +} + +function fakeS3Key(cycleName, applicationId, slot, ext) { + return `applications/${cycleSlug(cycleName)}/${applicationId}/${slot}/${randomUUID()}.${ext}` +} + +try { + const [cycle] = cycleArg + ? await sql` + select id, name + from public.rush_cycles + where name = ${cycleArg} + limit 1 + ` + : await sql` + select id, name + from public.rush_cycles + where is_active = true + limit 1 + ` + + if (!cycle) { + console.error( + cycleArg + ? `No rush cycle named "${cycleArg}".` + : 'No active rush cycle found. Pass --cycle "Your Cycle Name".' + ) + process.exit(1) + } + + const questions = await sql` + select id, sort_order + from public.cycle_questions + where cycle_id = ${cycle.id} + order by sort_order asc + ` + + if (questions.length === 0) { + console.error(`Cycle "${cycle.name}" has no questions.`) + process.exit(1) + } + + const [{ max_num: maxDisplay }] = await sql` + select coalesce(max(display_number), 0) as max_num + from public.applications + where cycle_id = ${cycle.id} + ` + + let nextDisplay = Number(maxDisplay) + 1 + let created = 0 + let skipped = 0 + + for (let i = 0; i < count; i += 1) { + const profile = APPLICANTS[i % APPLICANTS.length] + const index = i + 1 + const email = `seed-applicant-${index}@umich.edu` + const userId = randomUUID() + + const [existing] = await sql` + select id + from public.applications + where cycle_id = ${cycle.id} + and email = ${email} + limit 1 + ` + + if (existing) { + skipped += 1 + continue + } + + const submittedAt = new Date(Date.now() - index * 3600_000).toISOString() + const displayNumber = nextDisplay + nextDisplay += 1 + + const [app] = await sql` + insert into public.applications ( + cycle_id, + user_id, + email, + status, + submitted_at, + first_name, + last_name, + preferred_name, + pronouns, + phone, + majors, + minors, + graduation_year, + gpa, + semesters_remaining, + other_professional_fraternity, + campus_activities, + hear_about, + anything_else, + display_number, + review_count + ) + values ( + ${cycle.id}, + ${userId}, + ${email}, + 'submitted', + ${submittedAt}, + ${profile.first}, + ${profile.last}, + ${profile.preferred}, + 'they/them', + '734-555-0100', + ${profile.majors}, + null, + 2027, + 3.750, + 4, + false, + ${profile.activities}, + ${['Instagram', 'Word of mouth']}, + ${'Excited to rush KTP! (seed data)'}, + ${displayNumber}, + 0 + ) + returning id + ` + + const essayBodies = [ESSAY_1, ESSAY_2, ESSAY_3] + for (const question of questions) { + const body = essayBodies[question.sort_order] ?? `Seed essay for question ${question.sort_order + 1}.` + await sql` + insert into public.application_answers (application_id, question_id, body) + values (${app.id}, ${question.id}, ${body}) + on conflict (application_id, question_id) do nothing + ` + } + + const files = [ + { slot: 'photo', ext: 'png', mime: 'image/png', name: 'photo.png' }, + { slot: 'transcript', ext: 'pdf', mime: 'application/pdf', name: 'transcript.pdf' }, + { slot: 'resume', ext: 'pdf', mime: 'application/pdf', name: 'resume.pdf' }, + { + slot: 'resume_anonymized', + ext: 'pdf', + mime: 'application/pdf', + name: 'resume-anonymized.pdf', + }, + { + slot: 'life_app_screenshot', + ext: 'png', + mime: 'image/png', + name: 'life-app.png', + }, + ] + + for (const file of files) { + await sql` + insert into public.application_files ( + application_id, + slot, + s3_key, + mime_type, + original_filename + ) + values ( + ${app.id}, + ${file.slot}, + ${fakeS3Key(cycle.name, app.id, file.slot, file.ext)}, + ${file.mime}, + ${file.name} + ) + on conflict (application_id, slot) do nothing + ` + } + + created += 1 + console.log( + ` #${displayNumber} ${profile.first} ${profile.last} <${email}>` + ) + } + + console.log( + `\nCycle: ${cycle.name}\nCreated ${created} application(s), skipped ${skipped} existing.` + ) + if (created === 0 && skipped > 0) { + console.log( + 'All seed applicants already exist. Use a higher --count for new emails (seed-applicant-N@umich.edu).' + ) + } +} finally { + await sql.end() +} diff --git a/scripts/seed-local-admins.mjs b/scripts/seed-local-admins.mjs index cfafb1a..fbb2cc4 100644 --- a/scripts/seed-local-admins.mjs +++ b/scripts/seed-local-admins.mjs @@ -23,13 +23,18 @@ const sql = postgres(process.env.DATABASE_URL, { prepare: false }) try { for (const email of emails) { + await sql` + insert into public.brothers (umich_email, status) + values (${email}, 'active') + on conflict (umich_email) where umich_email is not null do nothing + ` await sql` insert into public.admins (email) values (${email}) on conflict (email) do nothing ` } - console.log(`Seeded ${emails.length} local admin email(s).`) + console.log(`Seeded ${emails.length} local admin email(s) as admins and brothers.`) } finally { await sql.end() } diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index f851dfa..91a6ddd 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -1,242 +1,307 @@ -'use client'; - -import React, { useState, useEffect, useRef } from 'react'; -import Link from 'next/link'; -import { Link as ScrollLink } from 'react-scroll'; -import { IoIosSpeedometer } from 'react-icons/io'; -import { MdOutlineWork } from "react-icons/md"; -import { FaPeopleGroup } from "react-icons/fa6"; -import { PiGlobeBold } from "react-icons/pi"; -import { HiAcademicCap } from "react-icons/hi2"; -import Header from '../../components/Header'; -import Footer from '../../components/Footer'; -const categories = ['President\'s Welcome', 'Our Pillars', 'History', 'DEI Commitment']; +import Image from 'next/image' +import { IoIosSpeedometer } from 'react-icons/io' +import { MdOutlineWork } from 'react-icons/md' +import { FaPeopleGroup } from 'react-icons/fa6' +import { PiGlobeBold } from 'react-icons/pi' +import { HiAcademicCap } from 'react-icons/hi2' +import AboutSectionNav from '@/components/AboutSectionNav' +import Header from '../../components/Header' +import Footer from '../../components/Footer' export default function About() { - const [selectedCategory, setSelectedCategory] = useState('President\'s Welcome'); - const categoryRefs = useRef<(HTMLDivElement | null)[]>([]); - - useEffect(() => { - // No longer needed since we're using button styling instead of underline - }, [selectedCategory]); - - const handleCategoryClick = (category: string, index: number) => { - setSelectedCategory(category); - }; - return (
-
- {/* Blob Container */} -
+ {/* Spill clip — `page-spill-clip` is relative only on mobile (desktop CB unchanged). + Hero blobs: top/left only — never inset-0 (bottom:0 makes the layer page-tall, + so top:8% on children is 8% of the document, not the viewport). */} +
+
-
- - {/* Blob Container */} -
-
-
-
-
- {/* Page Content */} -
-
-

- About Us -

-

- Learn more about who we are at Kappa Theta Pi! -

+
+
+
+

+ About Us +

+

+ Learn more about who we are at Kappa Theta Pi! +

+
-
- {/* Category filter buttons */} -
-
- {categories.map((category, index) => ( - handleCategoryClick(category, index)} - to={`${category.toLowerCase().replace(/\s+/g, '-')}-section`} - smooth={true} - duration={200} - ref={(el: unknown) => { categoryRefs.current[index] = el as HTMLDivElement | null; }} - > - {category} - - ))} +
+
-
- {/* Main content */} -
- {/* President's Welcome */} -
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+ President's Headshot
- President's Headshot -
-
-

President's Welcome

-
-

- Welcome to the Alpha Chapter of Kappa Theta Pi, Michigan's premier professional technology fraternity. On behalf of our chapter, I am excited to welcome you to our fraternity's website, where you can catch a glimpse of the passion and excellence that our chapter celebrates. -

-

- Kappa Theta Pi offers brothers the support to be extraordinary during their time at Michigan with resources centered around five pillars: professional development, alumni connections, social growth, technological advancement, and academic support. From project teams and study groups to professional development workshops and hackathons / design jams, we foster a culture of growth encouraging members to pursue their tech passions. Our chapter values diversity, with brothers contributing unique experiences and excelling as student leaders. We celebrate our diverse brotherhood, welcoming all united by a passion for technology. -

-

- Reflecting on my time at Michigan, KTP has been the most impactful part of my college experience. I joined as a sophomore transfer student, unsure of where my path in technology would take me, both in college and beyond. Since then, KTP has given me growth, direction, and a diverse community of students that supports one another through struggles and success. To me, being a part of KTP is about figuring out your place in the present and the future, surrounded by a brilliant and ambitious community of individuals doing just the same. I invite you to explore our website and learn more about our brotherhood.​ -

-

- With love,
- Teagan Hollman
- President, 2026 -

+
+

+ President's Welcome +

+
+

+ Welcome to the Alpha Chapter of Kappa Theta Pi, Michigan's premier + professional technology fraternity. On behalf of our chapter, I am excited to + welcome you to our fraternity's website, where you can catch a glimpse of + the passion and excellence that our chapter celebrates. +

+

+ Kappa Theta Pi offers brothers the support to be extraordinary during their + time at Michigan with resources centered around five pillars: professional + development, alumni connections, social growth, technological advancement, + and academic support. From project teams and study groups to professional + development workshops and hackathons / design jams, we foster a culture of + growth encouraging members to pursue their tech passions. Our chapter values + diversity, with brothers contributing unique experiences and excelling as + student leaders. We celebrate our diverse brotherhood, welcoming all united + by a passion for technology. +

+

+ Reflecting on my time at Michigan, KTP has been the most impactful part of my + college experience. I joined as a sophomore transfer student, unsure of where + my path in technology would take me, both in college and beyond. Since then, + KTP has given me growth, direction, and a diverse community of students that + supports one another through struggles and success. To me, being a part of + KTP is about figuring out your place in the present and the future, + surrounded by a brilliant and ambitious community of individuals doing just + the same. I invite you to explore our website and learn more about our + brotherhood. +

+

+ With love, +
+ Teagan Hollman +
+ President, 2026 +

+
-
- {/* Pillars */} -
-
-
-

Our Pillars

-
+
+
+
+

Our Pillars

+
-
- {/* Professional Development */} -
-
-
- +
+
+
+
+ +
+

Professional Development

+

+ Through events like interview training, resume building, one-on-one mentorship, + private company recruiting, and more, Kappa Theta Pi Professional Development + aims to prepare members for success in any technology-related career. We take + pride in developing the tech leaders of the future. +

-

Professional Development

-

Through events like interview training, resume building, one-on-one mentorship, private company recruiting, and more, Kappa Theta Pi Professional Development aims to prepare members for success in any technology-related career. We take pride in developing the tech leaders of the future.

-
- {/* Alumni Connections */} -
-
-
- +
+
+
+ +
+

Alumni Connections

+

+ Our alumni are spread out across the world and work on cutting-edge + technologies. They work at a plethora of companies - from tech companies like + Microsoft, Amazon, Facebook, Apple, and Google, to startups, consulting firms, + financial technology firms, and more! +

-

Alumni Connections

-

Our alumni are spread out across the world and work on cutting-edge technologies. They work at a plethora of companies - from tech companies like Microsoft, Amazon, Facebook, Apple, and Google, to startups, consulting firms, financial technology firms, and more!

-
- {/* Social Growth */} -
-
-
- +
+
+
+ +
+

Social Growth

+

+ The people you meet in Kappa Theta Pi will go on to be some of your closest + friends throughout college and beyond. We host a variety of exclusive social + events throughout the semester through which our members can bond, some of + which include formal, tailgates, retreat, and apple picking. +

-

Social Growth

-

The people you meet in Kappa Theta Pi will go on to be some of your closest friends throughout college and beyond. We host a variety of exclusive social events throughout the semester through which our members can bond, some of which include formal, tailgates, retreat, and apple picking.

-
- {/* Bottom Row */} -
- {/* Technical Advancement */} -
-
-
- +
+
+
+
+ +
+

Technical Advancement

+

+ Kappa Theta Pi provides members numerous opportunities to enhance their current + technical skills, as well as learn new ones. Whether it be participation in one + of our various project teams or attending a technical workshop, we make it easy + for our members to expand their expertise. +

-

Technical Advancement

-

Kappa Theta Pi provides members numerous opportunities to enhance their current technical skills, as well as learn new ones. Whether it be participation in one of our various project teams or attending a technical workshop, we make it easy for our members to expand their expertise.

-
- {/* Academic Support */} -
-
-
- +
+
+
+ +
+

Academic Support

+

+ Kappa Theta Pi brothers strive to foster academic growth and excellence for + each other. We provide a supportive network filled with some of the brightest + tech minds at the university that members can always rely on for help in + classes and extracurricular activities. +

-

Academic Support

-

Kappa Theta Pi brothers strive to foster academic growth and excellence for each other. We provide a supportive network filled with some of the brightest tech minds at the university that members can always rely on for help in classes and extracurricular activities.

-
- {/* History */} -
-
-
-
-

History

-
-

- Kappa Theta Pi takes pride in being the first professional technology fraternity in the country. Our members learn a plethora of skills needed to stay knowledgeable about the tech industry, as well as a strong sense of professional development for future job positions. -

-

- KTP was founded on January 10, 2012, with the mission to create a tech community that enthusiastic students could join. In making KTP, the founders set up a strong community that has only grown in the 11 years since its inception. -

-

- Our members come from all around campus. We are designers, analysts, computer scientists, engineers, artists, entrepreneurs, economists, philosophers, psychologists, and more. What makes the KTP community strong is our shared passion for technology and our unique backgrounds meshing together as one. -

-

- Our alumni are part of an extensive and tight-knit network that stretches across the country. They can be found from Seattle to New York, from Silicon Valley to Detroit, in both startup companies and larger businesses. Our alumni provide valuable insight for our members' professional development. -

+
+
+
+
+

History

+
+

+ Kappa Theta Pi takes pride in being the first professional technology + fraternity in the country. Our members learn a plethora of skills needed to + stay knowledgeable about the tech industry, as well as a strong sense of + professional development for future job positions. +

+

+ KTP was founded on January 10, 2012, with the mission to create a tech + community that enthusiastic students could join. In making KTP, the founders + set up a strong community that has only grown in the 11 years since its + inception. +

+

+ Our members come from all around campus. We are designers, analysts, computer + scientists, engineers, artists, entrepreneurs, economists, philosophers, + psychologists, and more. What makes the KTP community strong is our shared + passion for technology and our unique backgrounds meshing together as one. +

+

+ Our alumni are part of an extensive and tight-knit network that stretches + across the country. They can be found from Seattle to New York, from Silicon + Valley to Detroit, in both startup companies and larger businesses. Our + alumni provide valuable insight for our members' professional + development. +

+
-
-
-
-
-
+
+
+
+
+
+ KTP Founders
- KTP Founders
-
- {/* DEI Commitment */} -
-
-
-

DEI Commitment

-
-

- The world of technology is unique, diverse, and multi-faceted. We believe that our brothers should be too. In Kappa Theta Pi, we're passionate about cultivating an inclusive community that promotes and values diversity. Our dedication to diversity, equity, and inclusion is unwavering; these values are central to our mission and to our impact. We know that having heterogeneous perspectives helps generate better ideas to solve the nuanced problems of a changing — and increasingly diverse — world. +

+
+
+

+ DEI Commitment +

+
+

+ The world of technology is unique, diverse, and multi-faceted. We believe that + our brothers should be too. In Kappa Theta Pi, we're passionate about + cultivating an inclusive community that promotes and values diversity. Our + dedication to diversity, equity, and inclusion is unwavering; these values are + central to our mission and to our impact. We know that having heterogeneous + perspectives helps generate better ideas to solve the nuanced problems of a + changing — and increasingly diverse — world.

-

- In KTP, we have a responsibility to address structural inequality in our communities as well as the social and cultural dimensions of technology. We are committed to harnessing the best of KTP — our people, platform, and technical innovation — to make lasting change inside and outside of our organization. -

+

+ In KTP, we have a responsibility to address structural inequality in our + communities as well as the social and cultural dimensions of technology. We are + committed to harnessing the best of KTP — our people, platform, and technical + innovation — to make lasting change inside and outside of our organization. +

+
+
- -
- ); + ) } diff --git a/src/app/admin/actions.ts b/src/app/admin/actions.ts index 3479c0b..e7a7547 100644 --- a/src/app/admin/actions.ts +++ b/src/app/admin/actions.ts @@ -10,12 +10,29 @@ import { } from '@/lib/rush-event-schema' import { createRushEvent, - getRushEvents, + getRushEventsForCycle, patchRushEvent, removeRushEvent, reorderRushEvents, toClientRushEvent, } from '@/lib/rush-events' +import { parseAdminEmail } from '@/lib/admin-schema' +import { parseBrotherId, parseBrotherWrite, type BrotherFormInput } from '@/lib/brother-schema' +import { addAdminEmail, removeAdminEmail } from '@/lib/admins' +import { addBrotherRow, removeBrotherRow, searchBrothers, updateBrotherRow } from '@/lib/brothers' +import { parseCycleId, parseRushCycleApplication, parseRushCycleCreate, parseRushCycleMeta } from '@/lib/rush-cycle-schema' +import { + activateRushCycle, + closeRushCycleNow, + createRushCycle, + getCycleBundle, + listRushCycles, + openRushCycleNow, + saveRushCycle, + saveRushCycleMeta, +} from '@/lib/rush-cycles' +import { saveRushRubric } from '@/lib/rubric-admin' +import { parseRushRubricSave } from '@/lib/rubric-schema' export type RushEventInput = RushEventWrite @@ -27,31 +44,185 @@ async function requireAdmin() { return { user, error: null } } -export async function listRushEvents() { +function revalidateRush() { + revalidatePath('/admin') + revalidatePath('/admin/rush') + revalidatePath('/admin/apps') + revalidatePath('/rush') + revalidatePath('/apply') + revalidatePath('/portal/reads') +} + +function revalidateMembers() { + revalidatePath('/admin') + revalidatePath('/admin/members') + revalidatePath('/portal') +} + +export async function addBrother(input: BrotherFormInput) { + const auth = await requireAdmin() + if (auth.error) { + return { data: null, error: auth.error } + } + + const parsed = parseBrotherWrite(input) + if (parsed.error || !parsed.data) { + return { data: null, error: parsed.error } + } + + try { + const result = await addBrotherRow(parsed.data) + if (result.error) return { data: null, error: result.error } + revalidateMembers() + return { data: result.brother, error: null } + } catch (error) { + console.error('Error adding brother:', error) + return { data: null, error: 'Failed to add brother' } + } +} + +export async function updateBrother(id: string, input: BrotherFormInput) { + const auth = await requireAdmin() + if (auth.error) { + return { data: null, error: auth.error } + } + + const parsedId = parseBrotherId(id) + if (parsedId.error || !parsedId.data) { + return { data: null, error: parsedId.error } + } + + const parsed = parseBrotherWrite(input) + if (parsed.error || !parsed.data) { + return { data: null, error: parsed.error } + } + + try { + const result = await updateBrotherRow(parsedId.data, parsed.data) + if (result.error) return { data: null, error: result.error } + revalidateMembers() + return { data: result.brother, error: null } + } catch (error) { + console.error('Error updating brother:', error) + return { data: null, error: 'Failed to update brother' } + } +} + +export async function removeBrother(id: string) { + const auth = await requireAdmin() + if (auth.error) { + return { data: null, error: auth.error } + } + + const parsed = parseBrotherId(id) + if (parsed.error || !parsed.data) { + return { data: null, error: parsed.error } + } + + try { + const result = await removeBrotherRow(parsed.data, auth.user.email ?? '') + if (result.error) return { data: null, error: result.error } + revalidateMembers() + return { data: null, error: null } + } catch (error) { + console.error('Error removing brother:', error) + return { data: null, error: 'Failed to remove brother' } + } +} + +export async function searchBrothersAction(query: string) { const auth = await requireAdmin() if (auth.error) { return { data: null, error: auth.error } } - const events = await getRushEvents() + try { + const data = await searchBrothers(query, 8) + return { data, error: null } + } catch (error) { + console.error('Error searching brothers:', error) + return { data: null, error: 'Failed to search brothers' } + } +} + +export async function addAdmin(email: string) { + const auth = await requireAdmin() + if (auth.error) { + return { data: null, error: auth.error } + } + + const parsed = parseAdminEmail(email) + if (parsed.error || !parsed.data) { + return { data: null, error: parsed.error } + } + + try { + const result = await addAdminEmail(parsed.data) + if (result.error) return { data: null, error: result.error } + revalidateMembers() + return { data: result.admin, error: null } + } catch (error) { + console.error('Error adding admin:', error) + return { data: null, error: 'Failed to add admin' } + } +} + +export async function removeAdmin(email: string) { + const auth = await requireAdmin() + if (auth.error) { + return { data: null, error: auth.error } + } + + const parsed = parseAdminEmail(email) + if (parsed.error || !parsed.data) { + return { data: null, error: parsed.error } + } + + try { + const result = await removeAdminEmail(parsed.data, auth.user.email ?? '') + if (result.error) return { data: null, error: result.error } + revalidateMembers() + return { data: null, error: null } + } catch (error) { + console.error('Error removing admin:', error) + return { data: null, error: 'Failed to remove admin' } + } +} + +export async function listRushEvents(cycleId: string) { + const auth = await requireAdmin() + if (auth.error) { + return { data: null, error: auth.error } + } + + const parsedId = parseCycleId(cycleId) + if (parsedId.error || !parsedId.data) { + return { data: null, error: parsedId.error } + } + + const events = await getRushEventsForCycle(parsedId.data) return { data: events.map(toClientRushEvent), error: null } } -export async function insertRushEvent(event: RushEventInput) { +export async function insertRushEvent(cycleId: string, event: RushEventInput) { const auth = await requireAdmin() if (auth.error) { return { data: null, error: auth.error } } + const parsedId = parseCycleId(cycleId) + if (parsedId.error || !parsedId.data) { + return { data: null, error: parsedId.error } + } + const parsed = parseRushEvent(event) if (parsed.error || !parsed.data) { return { data: null, error: parsed.error } } try { - const created = await createRushEvent(parsed.data) - revalidatePath('/rush') - revalidatePath('/admin') + const created = await createRushEvent(parsedId.data, parsed.data) + revalidateRush() return { data: toClientRushEvent(created), error: null } } catch (error) { console.error('Error inserting rush event:', error) @@ -72,8 +243,7 @@ export async function deleteRushEvent(eventId: string) { try { await removeRushEvent(parsedId.data) - revalidatePath('/rush') - revalidatePath('/admin') + revalidateRush() return { data: null, error: null } } catch (error) { console.error('Error deleting rush event:', error) @@ -103,8 +273,7 @@ export async function updateRushEvent(eventId: string, event: RushEventInput) { if (!updated) { return { data: null, error: 'Event not found' } } - revalidatePath('/rush') - revalidatePath('/admin') + revalidateRush() return { data: toClientRushEvent(updated), error: null } } catch (error) { console.error('Error updating rush event:', error) @@ -127,11 +296,212 @@ export async function updateRushEventOrder( try { await reorderRushEvents(parsed.data) - revalidatePath('/rush') - revalidatePath('/admin') + revalidateRush() return { data: null, error: null } } catch (error) { console.error('Error updating order_index:', error) return { data: null, error: 'Failed to update event order' } } } + +export async function getRushCycleBundle(cycleId: string) { + const auth = await requireAdmin() + if (auth.error) { + return { data: null, error: auth.error } + } + + const parsedId = parseCycleId(cycleId) + if (parsedId.error || !parsedId.data) { + return { data: null, error: parsedId.error } + } + + const data = await getCycleBundle(parsedId.data) + if (!data) return { data: null, error: 'Cycle not found' } + return { data, error: null } +} + +export async function createRushCycleRecord(input: unknown) { + const auth = await requireAdmin() + if (auth.error) { + return { data: null, error: auth.error } + } + + const parsed = parseRushCycleCreate(input) + if (parsed.error || !parsed.data) { + return { data: null, error: parsed.error } + } + + try { + const bundle = await createRushCycle(parsed.data) + if (!bundle) return { data: null, error: 'Failed to create cycle' } + const cycles = await listRushCycles() + revalidateRush() + return { data: { ...bundle, cycles }, error: null } + } catch (error) { + console.error('Error creating rush cycle:', error) + return { data: null, error: 'Failed to create cycle' } + } +} + +export async function saveRushApplicationCycle(cycleId: string, input: unknown) { + const auth = await requireAdmin() + if (auth.error) { + return { data: null, error: auth.error } + } + + const parsedId = parseCycleId(cycleId) + if (parsedId.error || !parsedId.data) { + return { data: null, error: parsedId.error } + } + + const parsed = parseRushCycleApplication(input) + if (parsed.error || !parsed.data) { + return { data: null, error: parsed.error } + } + + try { + await saveRushCycle(parsedId.data, parsed.data) + revalidateRush() + const data = await getCycleBundle(parsedId.data) + if (!data) return { data: null, error: 'Cycle not found' } + return { data, error: null } + } catch (error) { + console.error('Error saving rush cycle:', error) + const message = error instanceof Error ? error.message : 'Failed to save rush application' + return { data: null, error: message } + } +} + +export async function saveRushCycleDetails(cycleId: string, input: unknown) { + const auth = await requireAdmin() + if (auth.error) { + return { data: null, error: auth.error } + } + + const parsedId = parseCycleId(cycleId) + if (parsedId.error || !parsedId.data) { + return { data: null, error: parsedId.error } + } + + const parsed = parseRushCycleMeta(input) + if (parsed.error || !parsed.data) { + return { data: null, error: parsed.error } + } + + try { + const updated = await saveRushCycleMeta(parsedId.data, parsed.data) + if (!updated) return { data: null, error: 'Cycle not found' } + revalidateRush() + const data = await getCycleBundle(parsedId.data) + if (!data) return { data: null, error: 'Cycle not found' } + return { data, error: null } + } catch (error) { + console.error('Error saving rush cycle:', error) + return { data: null, error: 'Failed to save rush cycle' } + } +} + +export async function showRushCycleOnSite(cycleId: string) { + const auth = await requireAdmin() + if (auth.error) { + return { data: null, error: auth.error } + } + + const parsedId = parseCycleId(cycleId) + if (parsedId.error || !parsedId.data) { + return { data: null, error: parsedId.error } + } + + try { + const updated = await activateRushCycle(parsedId.data) + if (!updated) return { data: null, error: 'Cycle not found' } + revalidateRush() + const [data, cycles] = await Promise.all([ + getCycleBundle(parsedId.data), + listRushCycles(), + ]) + if (!data) return { data: null, error: 'Cycle not found' } + return { data: { ...data, cycles }, error: null } + } catch (error) { + console.error('Error activating rush cycle:', error) + return { data: null, error: 'Failed to show cycle on site' } + } +} + +export async function closeRushApplicationNow(cycleId: string) { + const auth = await requireAdmin() + if (auth.error) { + return { data: null, error: auth.error } + } + + const parsedId = parseCycleId(cycleId) + if (parsedId.error || !parsedId.data) { + return { data: null, error: parsedId.error } + } + + try { + const updated = await closeRushCycleNow(parsedId.data) + if (!updated) return { data: null, error: 'Cycle not found' } + revalidateRush() + const data = await getCycleBundle(parsedId.data) + if (!data) return { data: null, error: 'Cycle not found' } + return { data, error: null } + } catch (error) { + console.error('Error closing rush cycle:', error) + return { data: null, error: 'Failed to close applications' } + } +} + +export async function openRushApplicationNow(cycleId: string) { + const auth = await requireAdmin() + if (auth.error) { + return { data: null, error: auth.error } + } + + const parsedId = parseCycleId(cycleId) + if (parsedId.error || !parsedId.data) { + return { data: null, error: parsedId.error } + } + + try { + const updated = await openRushCycleNow(parsedId.data) + if (!updated) return { data: null, error: 'Cycle not found' } + revalidateRush() + const [data, cycles] = await Promise.all([ + getCycleBundle(parsedId.data), + listRushCycles(), + ]) + if (!data) return { data: null, error: 'Cycle not found' } + return { data: { ...data, cycles }, error: null } + } catch (error) { + console.error('Error opening rush cycle:', error) + return { data: null, error: 'Failed to open applications' } + } +} + +export async function saveRushRubricCategories(cycleId: string, input: unknown) { + const auth = await requireAdmin() + if (auth.error) { + return { data: null, error: auth.error } + } + + const parsedId = parseCycleId(cycleId) + if (parsedId.error || !parsedId.data) { + return { data: null, error: parsedId.error } + } + + const parsed = parseRushRubricSave(input) + if (parsed.error || !parsed.data) { + return { data: null, error: parsed.error } + } + + try { + const categories = await saveRushRubric(parsedId.data, parsed.data) + revalidateRush() + return { data: { categories }, error: null } + } catch (error) { + console.error('Error saving rush rubric:', error) + const message = error instanceof Error ? error.message : 'Failed to save rubric' + return { data: null, error: message } + } +} diff --git a/src/app/admin/apps/[applicationId]/page.tsx b/src/app/admin/apps/[applicationId]/page.tsx new file mode 100644 index 0000000..c51d696 --- /dev/null +++ b/src/app/admin/apps/[applicationId]/page.tsx @@ -0,0 +1,41 @@ +import { redirect } from 'next/navigation' +import { AdminAppDetail } from '@/components/admin/AdminAppDetail' +import { AdminPageShell } from '@/components/admin/AdminPageShell' +import { AdminQuickLinks } from '@/components/admin/AdminQuickLinks' +import Unauthorized from '@/components/Unauthorized' +import { getAdjacentAdminApplications, getApplicationReviewDetailsForAdmin } from '@/lib/admin-applications' +import { getAdminCycle } from '@/lib/rush-cycles' +import { checkIsAdmin, getCurrentUser } from '@/lib/supabase/auth-helpers' + +export default async function AdminAppDetailPage({ + params, +}: { + params: Promise<{ applicationId: string }> +}) { + const { applicationId } = await params + const currentUser = await getCurrentUser() + if (!currentUser) redirect('/login') + + const adminUser = await checkIsAdmin() + if (!adminUser) return + + const { cycle } = await getAdminCycle() + if (!cycle) redirect('/admin/apps') + + const detail = await getApplicationReviewDetailsForAdmin(cycle.id, applicationId) + if (!detail) redirect('/admin/apps') + + const { prevId, nextId } = await getAdjacentAdminApplications(cycle.id, applicationId) + + return ( + + + + + ) +} diff --git a/src/app/admin/apps/actions.ts b/src/app/admin/apps/actions.ts new file mode 100644 index 0000000..69a7e84 --- /dev/null +++ b/src/app/admin/apps/actions.ts @@ -0,0 +1,179 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { eq } from 'drizzle-orm' +import { db } from '@/db' +import { applications, rushCycles } from '@/db/schema' +import { checkIsAdmin } from '@/lib/supabase/auth-helpers' +import { + buildApplicationsExportCsv, + deleteApplicationForAdmin, + getApplicationReviewDetailsForAdmin, + listApplicationsForAdmin, + type AdminApplicationSortKey, +} from '@/lib/admin-applications' +import { + addReviewAccessEntry, + listReviewAccessForCycle, + removeReviewAccessEntry, + updateReviewAccessMinimum, +} from '@/lib/review-access-admin' +import { getApplicationFileForSlot } from '@/lib/applications' +import { isApplicationFileKey } from '@/lib/apply-s3' +import { FILE_SLOTS, type FileSlot } from '@/lib/apply-steps' +import { createPresignedGetUrl } from '@/lib/s3' + +async function requireAdmin() { + const user = await checkIsAdmin() + if (!user) return { error: 'Unauthorized: Admin access required' as const, user: null } + return { error: null, user } +} + +function revalidateApps() { + revalidatePath('/admin/apps') + revalidatePath('/admin') + revalidatePath('/portal/reads') +} + +export async function listApplicationsForAdminAction(cycleId: string) { + const auth = await requireAdmin() + if (auth.error) return { error: auth.error, applications: null } + if (!cycleId) return { error: 'Missing cycle.', applications: null } + + const applications = await listApplicationsForAdmin(cycleId) + return { error: null, applications } +} + +export async function exportApplicationsCsvAction( + cycleId: string, + cycleName: string, + options?: { query?: string; sort?: AdminApplicationSortKey } +) { + const auth = await requireAdmin() + if (auth.error) return { error: auth.error, csv: null, filename: null } + if (!cycleId) return { error: 'Missing cycle.', csv: null, filename: null } + + const csv = await buildApplicationsExportCsv(cycleId, cycleName, options) + const slug = cycleName.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') + return { + error: null, + csv, + filename: `${slug || 'rush'}-applications.csv`, + } +} + +export async function listReviewAccessAction(cycleId: string) { + const auth = await requireAdmin() + if (auth.error) return { error: auth.error, entries: null } + if (!cycleId) return { error: 'Missing cycle.', entries: null } + + const entries = await listReviewAccessForCycle(cycleId) + return { error: null, entries } +} + +export async function addReviewAccessAction(input: { + cycleId: string + email: string + minRequiredReviews?: number +}) { + const auth = await requireAdmin() + if (auth.error) return { error: auth.error, entry: null } + + const result = await addReviewAccessEntry(input) + if (result.error) return { error: result.error, entry: null } + + revalidateApps() + return { error: null, entry: result.entry } +} + +export async function removeReviewAccessAction(id: string, cycleId: string) { + const auth = await requireAdmin() + if (auth.error) return { error: auth.error } + + const result = await removeReviewAccessEntry(id, cycleId) + if (result.error) return { error: result.error } + + revalidateApps() + return { error: null } +} + +export async function updateReviewAccessMinimumAction(input: { + id: string + cycleId: string + minRequiredReviews: number +}) { + const auth = await requireAdmin() + if (auth.error) return { error: auth.error, entry: null } + + const result = await updateReviewAccessMinimum(input) + if (result.error) return { error: result.error, entry: null } + + revalidateApps() + return { error: null, entry: result.entry } +} + +export async function getApplicationDetailAction(cycleId: string, applicationId: string) { + const auth = await requireAdmin() + if (auth.error) return { error: auth.error, detail: null } + if (!cycleId || !applicationId) return { error: 'Missing application.', detail: null } + + const detail = await getApplicationReviewDetailsForAdmin(cycleId, applicationId) + if (!detail) return { error: 'Application not found.', detail: null } + return { error: null, detail } +} + +export async function deleteApplicationAction(cycleId: string, applicationId: string) { + const auth = await requireAdmin() + if (auth.error) return { error: auth.error } + if (!cycleId || !applicationId) return { error: 'Missing application.' } + + const result = await deleteApplicationForAdmin(cycleId, applicationId) + if (result.error) return { error: result.error } + + revalidateApps() + revalidatePath(`/admin/apps/${applicationId}`) + return { error: null } +} + +export async function getAdminApplicationFileDownloadUrl(input: { + applicationId: string + slot: string +}) { + const auth = await requireAdmin() + if (auth.error) return { error: auth.error, downloadUrl: null } + if (!input.applicationId) return { error: 'Missing application.', downloadUrl: null } + if (!FILE_SLOTS.includes(input.slot as FileSlot)) { + return { error: 'Invalid file slot.' as const, downloadUrl: null } + } + + const fileSlot = input.slot as FileSlot + const [app] = await db + .select({ + id: applications.id, + cycleName: rushCycles.name, + }) + .from(applications) + .innerJoin(rushCycles, eq(rushCycles.id, applications.cycleId)) + .where(eq(applications.id, input.applicationId)) + .limit(1) + + if (!app) return { error: 'Application not found.', downloadUrl: null } + + const file = await getApplicationFileForSlot(app.id, fileSlot) + if (!file?.s3Key) return { error: 'File not found.', downloadUrl: null } + + if (!isApplicationFileKey(file.s3Key, app.id, fileSlot, app.cycleName)) { + return { error: 'Invalid file.', downloadUrl: null } + } + + const presigned = await createPresignedGetUrl({ + key: file.s3Key, + filename: file.originalFilename?.trim() || fileSlot, + contentType: file.mimeType, + }) + if (presigned.error || !presigned.downloadUrl) { + return { error: presigned.error ?? 'Could not prepare download.', downloadUrl: null } + } + + return { error: null, downloadUrl: presigned.downloadUrl } +} diff --git a/src/app/admin/apps/page.tsx b/src/app/admin/apps/page.tsx new file mode 100644 index 0000000..1cef38c --- /dev/null +++ b/src/app/admin/apps/page.tsx @@ -0,0 +1,184 @@ +import Link from 'next/link' +import { redirect } from 'next/navigation' +import { AdminAppsTable } from '@/components/admin/AdminAppsTable' +import { + AdminPageShell, + adminPageTitleClass, + adminPageTitleStyle, +} from '@/components/admin/AdminPageShell' +import { AdminQuickLinks } from '@/components/admin/AdminQuickLinks' +import { AdminReviewAccessManager } from '@/components/admin/AdminReviewAccessManager' +import { AdminReviewerProgressTable } from '@/components/admin/AdminReviewerProgressTable' +import { + adminHeadingClass, + adminInnerCardClass, + adminInnerCardStyle, + adminLinkClass, + adminMutedClass, + adminSectionCardClass, + adminSectionCardStyle, +} from '@/components/admin/admin-ui' +import Unauthorized from '@/components/Unauthorized' +import { listApplicationsForAdmin } from '@/lib/admin-applications' +import { listReviewerProgressForAdmin } from '@/lib/admin-reviewer-progress' +import { listReviewAccessForCycle } from '@/lib/review-access-admin' +import { getAdminCycle } from '@/lib/rush-cycles' +import { checkIsAdmin, getCurrentUser } from '@/lib/supabase/auth-helpers' + +export default async function AdminAppsPage() { + const currentUser = await getCurrentUser() + if (!currentUser) redirect('/login') + + const adminUser = await checkIsAdmin() + if (!adminUser) return + + const { cycle } = await getAdminCycle() + if (!cycle) { + return ( + +

+ Applications +

+ +

+ Create a rush cycle before managing applications. +

+ + Go to rush admin + +
+ ) + } + + const [applications, reviewAccess, reviewerProgress] = await Promise.all([ + listApplicationsForAdmin(cycle.id), + listReviewAccessForCycle(cycle.id), + listReviewerProgressForAdmin(cycle.id), + ]) + + const totalReads = applications.reduce((sum, app) => sum + app.readCount, 0) + const avgReadsPerApp = + applications.length > 0 ? (totalReads / applications.length).toFixed(1) : '—' + const avgReviewDurationMs = (() => { + const withDuration = reviewerProgress.filter((r) => r.avgDurationMs != null) + if (withDuration.length === 0) return null + return ( + withDuration.reduce((sum, r) => sum + (r.avgDurationMs ?? 0), 0) / + withDuration.length + ) + })() + const metMinimumCount = reviewerProgress.filter( + (r) => r.completedCount >= r.minRequiredReviews + ).length + + return ( + +

+ Applications +

+ + + +
+
+

{cycle.name}

+

+ EBoard overview for this rush cycle. Reviewers only see anonymized applications. +

+
+ +
+
+
+

+ Submitted +

+

{applications.length}

+
+
+
+
+

+ Total reads +

+

{totalReads}

+
+
+
+
+

+ Reviewers met min +

+

+ {metMinimumCount} + + {' '} + / {reviewAccess.length} + +

+
+
+
+
+

+ Avg reads / app +

+

{avgReadsPerApp}

+
+
+
+
+

+ Avg review time +

+

+ {avgReviewDurationMs == null + ? '—' + : Math.round(avgReviewDurationMs / 60000) < 1 + ? '<1 min' + : `${Math.round(avgReviewDurationMs / 60000)} min`} +

+
+
+
+
+ +
+
+

+ Submitted applications +

+

+ Search, sort, and export the full applicant list. +

+
+ +
+ +
+
+

Reviewer access

+

+ Brothers on this list can use Application Reads. They must already be in the brothers + directory. Site admins can always review. +

+
+ +
+ +
+
+

Reviewer progress

+

+ Completed reads, remaining toward each reviewer's minimum, and average time per app. +

+
+ +
+
+ ) +} diff --git a/src/app/admin/debug/S3UploadSpike.tsx b/src/app/admin/debug/S3UploadSpike.tsx new file mode 100644 index 0000000..3761232 --- /dev/null +++ b/src/app/admin/debug/S3UploadSpike.tsx @@ -0,0 +1,183 @@ +'use client' + +import { useRef, useState } from 'react' +import { debugPresignPdfUpload, debugVerifyS3Upload } from '@/app/admin/debug/actions' + +type Step = 'idle' | 'presigning' | 'uploading' | 'verifying' | 'done' | 'error' + +type VerifiedObject = { + key: string + bucket: string + contentType: string | null + sizeBytes: number | null + etag: string | null + lastModified: string | null +} + +export function S3UploadSpike({ + s3Configured, + bucket, + region, +}: { + s3Configured: boolean + bucket: string | null + region: string | null +}) { + const inputRef = useRef(null) + const [step, setStep] = useState('idle') + const [message, setMessage] = useState(null) + const [filename, setFilename] = useState(null) + const [object, setObject] = useState(null) + + async function onChooseFile(event: React.ChangeEvent) { + const file = event.target.files?.[0] + if (!file) return + + setFilename(file.name) + setObject(null) + setMessage(null) + + if (file.type !== 'application/pdf') { + setStep('error') + setMessage('Choose a PDF file.') + if (inputRef.current) inputRef.current.value = '' + setFilename(null) + return + } + + try { + setStep('presigning') + const presigned = await debugPresignPdfUpload({ + contentType: file.type, + sizeBytes: file.size, + }) + if (presigned.error || !presigned.uploadUrl || !presigned.key) { + setStep('error') + setMessage(presigned.error ?? 'Could not get presigned URL.') + return + } + + setStep('uploading') + const uploadResponse = await fetch(presigned.uploadUrl, { + method: 'PUT', + body: file, + headers: { 'Content-Type': file.type }, + }) + if (!uploadResponse.ok) { + setStep('error') + setMessage(`Upload failed (${uploadResponse.status}). Check bucket CORS.`) + return + } + + setStep('verifying') + const verified = await debugVerifyS3Upload(presigned.key) + if (verified.error || !verified.object) { + setStep('error') + setMessage(verified.error ?? 'Upload succeeded but verification failed.') + return + } + + setObject(verified.object) + setStep('done') + setMessage('Upload verified.') + } catch (error) { + setStep('error') + setMessage(error instanceof Error ? error.message : 'Upload failed.') + } + } + + function reset() { + setStep('idle') + setMessage(null) + setFilename(null) + setObject(null) + if (inputRef.current) inputRef.current.value = '' + } + + const busy = step === 'presigning' || step === 'uploading' || step === 'verifying' + const statusLabel = + step === 'presigning' + ? 'Preparing upload…' + : step === 'uploading' + ? 'Uploading…' + : step === 'verifying' + ? 'Verifying…' + : null + + return ( +
+ {!s3Configured ? ( +

+ S3 env vars are missing in .env.local. Restart dev after setting them. +

+ ) : ( +

+ Bucket: {bucket} + {region ? ( + <> + {' '} + · Region: {region} + + ) : null} +

+ )} + + void onChooseFile(event)} + /> + +
+
+ {busy ? statusLabel : filename ?? 'No file chosen'} +
+ + {filename && !busy ? ( + + ) : null} +
+ + {message ? ( +

{message}

+ ) : null} + + {object ? ( +
+
+
Key
+
{object.key}
+
+
+
Size
+
{object.sizeBytes?.toLocaleString() ?? '—'} bytes
+
+
+
ETag
+
{object.etag ?? '—'}
+
+
+ ) : null} +
+ ) +} diff --git a/src/app/admin/debug/actions.ts b/src/app/admin/debug/actions.ts new file mode 100644 index 0000000..1f1fb4f --- /dev/null +++ b/src/app/admin/debug/actions.ts @@ -0,0 +1,74 @@ +'use server' + +import { randomUUID } from 'crypto' +import { createPresignedPutUrl, headS3Object } from '@/lib/s3' +import { checkIsAdmin } from '@/lib/supabase/auth-helpers' + +const DEBUG_PDF_MAX_BYTES = 10 * 1024 * 1024 +const DEBUG_PDF_MIME = 'application/pdf' + +async function requireAdmin() { + const user = await checkIsAdmin() + if (!user) { + return { error: 'Unauthorized: Admin access required' as const, user: null } + } + return { error: null, user } +} + +function debugKeyForUser(userId: string) { + return `debug/${userId}/${randomUUID()}.pdf` +} + +function isOwnedDebugKey(key: string, userId: string) { + return key.startsWith(`debug/${userId}/`) && key.endsWith('.pdf') && !key.includes('..') +} + +export async function debugPresignPdfUpload(input: { contentType: string; sizeBytes: number }) { + const auth = await requireAdmin() + if (auth.error || !auth.user) { + return { error: auth.error, uploadUrl: null, key: null, bucket: null } + } + + if (input.contentType !== DEBUG_PDF_MIME) { + return { error: 'Only PDF files are allowed.', uploadUrl: null, key: null, bucket: null } + } + + if (input.sizeBytes <= 0 || input.sizeBytes > DEBUG_PDF_MAX_BYTES) { + return { + error: `File must be between 1 byte and ${DEBUG_PDF_MAX_BYTES / (1024 * 1024)} MB.`, + uploadUrl: null, + key: null, + bucket: null, + } + } + + const key = debugKeyForUser(auth.user.id) + const presigned = await createPresignedPutUrl({ + key, + contentType: DEBUG_PDF_MIME, + }) + + if (presigned.error || !presigned.uploadUrl) { + return { error: presigned.error, uploadUrl: null, key: null, bucket: null } + } + + return { + error: null, + uploadUrl: presigned.uploadUrl, + key, + bucket: presigned.bucket, + } +} + +export async function debugVerifyS3Upload(key: string) { + const auth = await requireAdmin() + if (auth.error || !auth.user) { + return { error: auth.error, object: null } + } + + if (!isOwnedDebugKey(key, auth.user.id)) { + return { error: 'Invalid key.', object: null } + } + + return headS3Object(key) +} diff --git a/src/app/admin/debug/page.tsx b/src/app/admin/debug/page.tsx index b9c66b1..7470a71 100644 --- a/src/app/admin/debug/page.tsx +++ b/src/app/admin/debug/page.tsx @@ -1,29 +1,26 @@ -import { createClient } from '@/lib/supabase/server' +import { redirect } from 'next/navigation' import Link from 'next/link' import Header from '@/components/Header' +import Unauthorized from '@/components/Unauthorized' +import { getS3ConfigStatus } from '@/lib/s3' +import { checkIsAdmin, getCurrentUser } from '@/lib/supabase/auth-helpers' +import { createClient } from '@/lib/supabase/server' +import { S3UploadSpike } from '@/app/admin/debug/S3UploadSpike' export default async function DebugPage() { - const supabase = await createClient() - - const { - data: { user }, - error: userError, - } = await supabase.auth.getUser() - - let adminCheck = null - let adminError = null - if (user?.email) { - const result = await supabase - .from('admins') - .select('*') - .eq('email', user.email.toLowerCase()) - .single() + const currentUser = await getCurrentUser() + if (!currentUser) { + redirect('/login') + } - adminCheck = result.data - adminError = result.error + const adminUser = await checkIsAdmin() + if (!adminUser) { + return } + const supabase = await createClient() const { data: allAdmins } = await supabase.from('admins').select('*') + const s3Status = getS3ConfigStatus() const sectionCardClass = 'rounded-xl border border-gray-100 p-6 transform transition-all duration-300 ease-in-out hover:shadow-[0_12px_36px_rgba(0,0,0,0.1),0_4px_12px_rgba(0,0,0,0.05)]' @@ -54,46 +51,24 @@ export default async function DebugPage() {
-

User Info

- {userError && ( -

Error: {userError.message}

- )} - {user ? ( -
-

- Email: {user.email} -

-

- User ID: {user.id} -

-

- Email ends with @umich.edu:{' '} - {user.email?.endsWith('@umich.edu') ? 'Yes' : 'No'} -

-
- ) : ( -

No user found. Please log in.

- )} +

S3 Upload Test

+
-

Admin Check

- {adminError && ( -

- Error: {adminError.message} (Code: {adminError.code}) -

- )} - {adminCheck ? ( -

- You are in the admins table. +

User Info

+
+

+ Email: {adminUser.email}

- ) : user ? ( -

- You are not in the admins table. Your email is: {user.email} +

+ User ID: {adminUser.id}

- ) : ( -

Cannot check — no user logged in.

- )} +
@@ -101,16 +76,11 @@ export default async function DebugPage() { {allAdmins && allAdmins.length > 0 ? (