diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..16741ab --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,4 @@ +# CODEDDEVS TECHNOLOGY LTD — Code Owners +# All PRs targeting main or dev require review from the repo owner before merging + +* @onerandomdevv diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..3a65e05 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,21 @@ +--- +name: Bug report +about: Something is broken +labels: bug +--- + +## What is broken? + +## Steps to reproduce +1. +2. +3. + +## Expected behaviour + +## Actual behaviour + +## Environment +- Branch: +- Node version: +- Browser (if UI): diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..8e59070 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,16 @@ +--- +name: Feature request +about: Suggest something new +labels: enhancement +--- + +## What do you want to build? + +## Why is it needed? + +## Acceptance criteria +- [ ] +- [ ] +- [ ] + +## Notes diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..ecd0043 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,25 @@ +## What does this PR do? + + +## Type of change +- [ ] Feature +- [ ] Bug fix +- [ ] Config / setup +- [ ] Refactor +- [ ] Docs + +## Checklist +- [ ] I have read CLAUDE.md +- [ ] pnpm build passes locally with no errors +- [ ] No TypeScript errors (pnpm tsc --noEmit) +- [ ] No hardcoded secrets or API keys +- [ ] All new API routes check for admin session before executing +- [ ] No UI libraries were installed +- [ ] Fonts are loaded via next/font/google only +- [ ] pnpm was used (not npm or yarn) + +## Screenshots (if UI changes) + + +## Notes for reviewer + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3052e49 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: CI + +on: + push: + branches: + - main + - dev + pull_request: + branches: + - main + - dev + +jobs: + ci: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v3 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: TypeScript check + run: pnpm tsc --noEmit + + - name: ESLint + run: pnpm eslint src/ --ext .ts,.tsx --max-warnings 0 + + - name: Build check + run: pnpm build + env: + DATABASE_URL: postgresql://dummy:dummy@dummy/dummy + DATABASE_URL_UNPOOLED: postgresql://dummy:dummy@dummy/dummy + NEXTAUTH_SECRET: dummy-secret-for-ci-only-not-real + NEXTAUTH_URL: http://localhost:3000 + NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME: dummy + NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET: dummy + CLOUDINARY_API_KEY: dummy + CLOUDINARY_API_SECRET: dummy + RESEND_API_KEY: dummy + CONTACT_NOTIFICATION_EMAIL: ci@dummy.com diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 0000000..bc94feb --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,22 @@ +name: Preview + +on: + pull_request: + branches: + - main + +jobs: + preview: + runs-on: ubuntu-latest + + steps: + - name: Comment preview status + uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: "✅ CI passed. Vercel preview deployment will be available shortly.", + }); diff --git a/AGENTS.md b/AGENTS.md index fab8206..c40a012 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,12 +6,13 @@ ## 1. What This Project Is -Company website for **CODEDDEVS TECHNOLOGY LTD** (RC: 9426867). +Official company website for **CODEDDEVS TECHNOLOGY LTD** (RC: 9426867). - **URL:** codeddevs.com (placeholder until domain is confirmed) - **Audience:** Investors, press, and partners — NOT merchants, buyers, or end users -- **Purpose:** Establish credibility, introduce the company and team, showcase twizrr, share company updates via blog +- **Purpose:** Present CODEDDEVS as a serious, product-driven technology company. Communicate what we are building, what's coming next, and how our products are evolving. Share product updates, releases, version changes, roadmaps, and announcements. - **Tone:** Professional, minimal, text-first — like Anthropic.com or Stripe.com +- **This is NOT a portfolio site.** Do not treat it like a project showcase or personal portfolio. It is an official company website structured the way established tech companies present themselves. - **This is NOT the twizrr product site.** twizrr.com is a completely separate codebase and repo. Every mention of twizrr on this site links OUT to twizrr.com. --- @@ -118,25 +119,27 @@ codeddevs-website/ ├── src/ │ ├── app/ │ │ ├── (public)/ -│ │ │ ├── layout.tsx # Navbar + Footer -│ │ │ ├── page.tsx # / Home -│ │ │ ├── about/page.tsx # /about -│ │ │ ├── projects/page.tsx # /projects +│ │ │ ├── layout.tsx # Navbar + Footer +│ │ │ ├── page.tsx # / Home +│ │ │ ├── about/page.tsx # /about +│ │ │ ├── products/ +│ │ │ │ ├── page.tsx # /products (all products list) +│ │ │ │ └── [slug]/page.tsx # /products/[slug] (dedicated product page) │ │ │ ├── blog/ -│ │ │ │ ├── page.tsx # /blog (list) -│ │ │ │ └── [slug]/page.tsx # /blog/[slug] -│ │ │ ├── team/page.tsx # /team -│ │ │ ├── careers/page.tsx # /careers -│ │ │ └── contact/page.tsx # /contact +│ │ │ │ ├── page.tsx # /blog (updates list, displayed as "Updates") +│ │ │ │ └── [slug]/page.tsx # /blog/[slug] +│ │ │ ├── team/page.tsx # /team +│ │ │ ├── careers/page.tsx # /careers +│ │ │ └── contact/page.tsx # /contact │ │ ├── admin/ -│ │ │ ├── layout.tsx # Admin sidebar layout -│ │ │ ├── login/page.tsx # /admin/login -│ │ │ ├── dashboard/page.tsx # /admin/dashboard +│ │ │ ├── layout.tsx # Admin sidebar layout +│ │ │ ├── login/page.tsx # /admin/login +│ │ │ ├── dashboard/page.tsx # /admin/dashboard │ │ │ ├── team/ │ │ │ │ ├── page.tsx │ │ │ │ ├── new/page.tsx │ │ │ │ └── [id]/page.tsx -│ │ │ ├── projects/ +│ │ │ ├── products/ │ │ │ │ ├── page.tsx │ │ │ │ ├── new/page.tsx │ │ │ │ └── [id]/page.tsx @@ -157,9 +160,9 @@ codeddevs-website/ │ │ │ ├── upload/route.ts │ │ │ └── admin/ │ │ │ ├── team/ -│ │ │ │ ├── route.ts # GET, POST -│ │ │ │ └── [id]/route.ts # GET, PUT, DELETE -│ │ │ ├── projects/ +│ │ │ │ ├── route.ts # GET, POST +│ │ │ │ └── [id]/route.ts # GET, PUT, DELETE +│ │ │ ├── products/ │ │ │ │ ├── route.ts │ │ │ │ └── [id]/route.ts │ │ │ ├── blog/ @@ -174,7 +177,7 @@ codeddevs-website/ │ │ │ └── messages/ │ │ │ ├── route.ts │ │ │ └── [id]/route.ts -│ │ ├── layout.tsx # Root layout (fonts, metadata) +│ │ ├── layout.tsx # Root layout (fonts, metadata) │ │ ├── not-found.tsx │ │ └── globals.css │ ├── components/ @@ -190,32 +193,32 @@ codeddevs-website/ │ │ │ └── Textarea.tsx │ │ ├── sections/ │ │ │ ├── HeroSection.tsx -│ │ │ ├── ProjectsSection.tsx -│ │ │ ├── HackathonStrip.tsx +│ │ │ ├── ProductsSection.tsx +│ │ │ ├── LatestReleasesSection.tsx # Home page — pulls 3 latest blog posts │ │ │ └── TeamSection.tsx │ │ └── admin/ -│ │ ├── RichTextEditor.tsx # TipTap wrapper -│ │ ├── ImageUpload.tsx # Cloudinary uploader +│ │ ├── RichTextEditor.tsx # TipTap wrapper +│ │ ├── ImageUpload.tsx # Cloudinary uploader │ │ └── DataTable.tsx │ ├── db/ -│ │ ├── index.ts # Drizzle client (Neon) -│ │ ├── schema.ts # All table definitions -│ │ └── migrations/ # Drizzle-generated — never edit manually +│ │ ├── index.ts # Drizzle client (Neon) +│ │ ├── schema.ts # All table definitions +│ │ └── migrations/ # Drizzle-generated — never edit manually │ ├── lib/ -│ │ ├── auth.ts # NextAuth v5 config -│ │ ├── email.ts # Resend helpers -│ │ ├── cloudinary.ts # Cloudinary config -│ │ └── utils.ts # cn() helper +│ │ ├── auth.ts # NextAuth v5 config +│ │ ├── email.ts # Resend helpers +│ │ ├── cloudinary.ts # Cloudinary config +│ │ └── utils.ts # cn() and slugify helpers │ └── types/ -│ └── index.ts # Shared TypeScript types +│ └── index.ts # Shared TypeScript types ├── drizzle.config.ts -├── middleware.ts # Route protection +├── middleware.ts # Route protection ├── next.config.ts ├── tailwind.config.ts ├── tsconfig.json -├── .env.local # Never commit this -├── .env.example # Commit this (values empty) -├── AGENTS.md # This file +├── .env.local # Never commit this +├── .env.example # Commit this (values empty) +├── CLAUDE.md # This file └── package.json ``` @@ -237,25 +240,25 @@ github_url: text twitter_url: text order_index: integer, notNull, default(0) is_active: boolean, notNull, default(true) -created_at: timestamp, defaultNow() -updated_at: timestamp, defaultNow() +created_at: timestamp, defaultNow(), notNull +updated_at: timestamp, defaultNow(), notNull ``` -### projects +### products ```ts id: uuid, primaryKey, defaultRandom() name: text, notNull slug: text, notNull, unique tagline: text, notNull -description: text, notNull +description: text, notNull // full product description cover_url: text // Cloudinary URL -external_url: text // e.g. twizrr.com +external_url: text // e.g. twizrr.com — always opens externally github_url: text status: text, notNull // 'development' | 'live' | 'archived' is_featured: boolean, notNull, default(false) order_index: integer, notNull, default(0) -created_at: timestamp, defaultNow() -updated_at: timestamp, defaultNow() +created_at: timestamp, defaultNow(), notNull +updated_at: timestamp, defaultNow(), notNull ``` ### blog_posts @@ -265,12 +268,13 @@ title: text, notNull slug: text, notNull, unique excerpt: text, notNull content: json, notNull // TipTap JSON -cover_url: text +cover_url: text // Cloudinary URL author: text, notNull, default('CODEDDEVS') +category: text, notNull // 'Product Update' | 'Announcement' | 'Roadmap' | 'Story' is_published: boolean, notNull, default(false) published_at: timestamp -created_at: timestamp, defaultNow() -updated_at: timestamp, defaultNow() +created_at: timestamp, defaultNow(), notNull +updated_at: timestamp, defaultNow(), notNull ``` ### careers @@ -282,21 +286,21 @@ location: text, notNull, default('Lagos, Nigeria / Remote') description: text, notNull requirements: text, notNull is_open: boolean, notNull, default(true) -created_at: timestamp, defaultNow() -updated_at: timestamp, defaultNow() +created_at: timestamp, defaultNow(), notNull +updated_at: timestamp, defaultNow(), notNull ``` ### career_applications ```ts id: uuid, primaryKey, defaultRandom() -career_id: uuid, references(careers.id) +career_id: uuid, notNull, references(careers.id) onDelete cascade full_name: text, notNull email: text, notNull portfolio_url: text github_url: text cover_letter: text, notNull status: text, notNull, default('pending') // 'pending' | 'reviewed' | 'rejected' -created_at: timestamp, defaultNow() +created_at: timestamp, defaultNow(), notNull ``` ### contact_submissions @@ -307,7 +311,7 @@ email: text, notNull subject: text, notNull message: text, notNull is_read: boolean, notNull, default(false) -created_at: timestamp, defaultNow() +created_at: timestamp, defaultNow(), notNull ``` ### admin_users @@ -315,7 +319,7 @@ created_at: timestamp, defaultNow() id: uuid, primaryKey, defaultRandom() email: text, notNull, unique password_hash: text, notNull // bcrypt hash -created_at: timestamp, defaultNow() +created_at: timestamp, defaultNow(), notNull ``` --- @@ -361,8 +365,8 @@ Use `DATABASE_URL_UNPOOLED` only in `drizzle.config.ts` for migrations. | POST | `/api/upload` | Upload image to Cloudinary | | GET/POST | `/api/admin/team` | List / create team members | | GET/PUT/DELETE | `/api/admin/team/[id]` | Read / update / delete | -| GET/POST | `/api/admin/projects` | List / create projects | -| GET/PUT/DELETE | `/api/admin/projects/[id]` | Read / update / delete | +| GET/POST | `/api/admin/products` | List / create products | +| GET/PUT/DELETE | `/api/admin/products/[id]` | Read / update / delete | | GET/POST | `/api/admin/blog` | List / create posts | | GET/PUT/DELETE | `/api/admin/blog/[id]` | Read / update / delete | | GET/POST | `/api/admin/careers` | List / create listings | @@ -392,32 +396,51 @@ export const config = { --- -## 9. Page Content +## 9. Page Content & Structure ### Home (/) - **Hero headline:** "Engineering Software That Works for Africa" - **Hero subtext:** "We build AI-first software products for African markets — from first principles, not adaptations." -- **CTAs:** "See Our Work" → /projects | "Get in Touch" → /contact -- **Products section:** twizrr card only — status "In Development" — links to twizrr.com -- **Hackathon strip:** 3 wins shown as text achievements (social proof) +- **CTAs:** "See Our Products" → /products | "Get in Touch" → /contact +- **Products section:** twizrr card only — status "In Development" — links externally to twizrr.com +- **Latest Releases section:** pulls the 3 most recently published blog posts automatically. Section heading: "Latest Releases". Each card shows: title, excerpt, date, category badge, and a dynamic CTA button based on category: + - "Product Update" → "Read the update →" + - "Announcement" → "Read the announcement →" + - "Roadmap" → "Read the roadmap →" + - "Story" → "Read the story →" +- **Hackathon achievements strip:** 3 wins shown as plain text social proof — not cards, not a showcase - **About teaser:** 2 sentences + "Meet the Team" → /team ### About (/about) - Mission: building AI-first software for African markets -- Approach: from first principles, not adapting tools built elsewhere +- Approach: from first principles — not adapting tools built elsewhere - Open-source commitment - Company facts: RC 9426867 | Lagos, Nigeria | Est. March 2026 +- This page explains WHO we are and WHY we exist — not what we have built -### Projects (/projects) -- Pulls from `projects` table +### Products (/products) +- Renamed from "Projects" — this is NOT a project showcase +- Pulls from `products` table +- Each product gets a card: name, tagline, status badge, external link - Currently: twizrr only (status: In Development) -- Each card: name, tagline, status badge, external link to product site -- Hackathon achievements shown as a separate text section below projects +- Each product links to its own dedicated page at /products/[slug] +- Hackathon achievements are NOT listed here — they belong in the blog as Story posts + +### Products — Dedicated Page (/products/[slug]) +- Full dedicated page per product +- Shows: name, tagline, full description, status, cover image +- External link button: "Visit [product name] →" opens externally +- GitHub link if available +- Related blog posts: pulls blog_posts where category = 'Product Update' filtered by product mention (or manually tagged — TBD) ### Blog (/blog) +- Displayed in the nav and on the page as **"Updates"** — the URL stays `/blog` +- This is the company's communication engine — product updates, releases, roadmaps, announcements, stories - Lists `blog_posts` where `is_published = true`, ordered by `published_at DESC` -- Shows: title, excerpt, date, author -- `/blog/[slug]` renders TipTap JSON content +- Shows: title, excerpt, date, author, category badge +- Filterable by category: All | Product Update | Announcement | Roadmap | Story +- `/blog/[slug]` renders the full TipTap JSON content +- Hackathon achievements are documented here as **Story** category posts ### Team (/team) - Pulls from `team_members` table ordered by `order_index` @@ -473,12 +496,16 @@ Follow every rule below on every task. No exceptions. 15. **Import alias.** Always use `@/` imports. Never use relative `../../` imports. -16. **twizrr links are always external.** Every link to twizrr on this site uses `target="_blank" rel="noopener noreferrer"` and points to `twizrr.com`. +16. **Product/twizrr links are always external.** Every link to any product site uses `target="_blank" rel="noopener noreferrer"`. 17. **migrations/ is read-only.** Never manually edit files in `src/db/migrations/`. Only Drizzle Kit writes to that folder. 18. **Logo usage.** The logo file is `/public/full-logo.png`. Always display it in the Navbar linked to `/`. Never recreate the logo in code. +19. **"Projects" is now "Products".** The table is named `products`, the route is `/products`, the admin section is `/admin/products`, the API is `/api/admin/products`. Never use the word "projects" anywhere in the UI, routes, or code. + +20. **Blog is displayed as "Updates".** The URL and internal references stay as `/blog`. But every user-facing label — nav link, page heading, section titles — uses "Updates" not "Blog". + --- ## 11. Company Details (Reference) @@ -505,6 +532,8 @@ Follow every rule below on every task. No exceptions. | Amoo Mustakheem Olamilekan | Co-Founder & COO | ### Hackathon Achievements +These are NOT products. Document them as Story posts in the blog. + | Project | Event | Result | |---|---|---| | DialAI | Build with AT: Generative AI + APIs Across Africa (Feb 2026) | 1st Place | @@ -513,52 +542,29 @@ Follow every rule below on every task. No exceptions. --- +## 12. Security -# 11. SECURITY - -## Secrets & Credentials -- Never check secrets into the repo or include them in prompts. -- Use environment variables and a secrets manager for local and CI use. -- `.env.local` is listed in `.gitignore` and must never be committed. Use `.env.example` for placeholder keys. - -## Environment Variables (reference) - -```bash -# Database -DATABASE_URL= -DATABASE_URL_UNPOOLED= - -# Auth -NEXTAUTH_SECRET= -NEXTAUTH_URL=http://localhost:3000 - -# Cloudinary -NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME= -NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET= -CLOUDINARY_API_KEY= -CLOUDINARY_API_SECRET= +### Secrets & Credentials +- Never check secrets into the repo or include them in prompts +- Use environment variables for all sensitive values +- `.env.local` is in `.gitignore` and must never be committed +- Use `.env.example` for placeholder keys only -# Resend -RESEND_API_KEY= -CONTACT_NOTIFICATION_EMAIL=codeddevs.team@gmail.com -``` +### Permissions & Review +- All PRs must be reviewed by @onerandomdevv before merging +- Any change touching infra, deployment, or secret handling requires explicit human approval +- Agent-generated code must be reviewed before merge — never auto-merge -- Use `DATABASE_URL` for app queries; `DATABASE_URL_UNPOOLED` only in `drizzle.config.ts` for migrations. -- Never paste secrets into prompts or store them in generated code. +### Data Privacy +- Do not send user PII to external APIs unless absolutely necessary +- If required, anonymise before sending +- TipTap content in DB is treated as site data — handle uploads and attachments carefully -## Permissions & Review -- Agent-generated PRs must be reviewed by an authorized maintainer before merge. -- Any change that touches infra, deployment, or secret handling requires explicit human approval. +### Incident Response +- If credentials are exposed, rotate the keys immediately +- Keep an audit trail of the incident and actions taken +- Security contact: codeddevs.team@gmail.com -## Data Privacy -- Avoid sending user PII or private data to external APIs. If necessary, anonymize before sending. -- TipTap content stored in DB is treated as site data — not PII — but treat uploads and attachments carefully. - -## Incident Response -- If an agent exposes credentials or sensitive data, rotate the keys immediately and notify security@company.example. -- Keep an audit trail of the incident and the actions taken. - -## Contacts -- Security contact: security@company.example +--- *Last updated: May 2026* \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index fab8206..c40a012 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,12 +6,13 @@ ## 1. What This Project Is -Company website for **CODEDDEVS TECHNOLOGY LTD** (RC: 9426867). +Official company website for **CODEDDEVS TECHNOLOGY LTD** (RC: 9426867). - **URL:** codeddevs.com (placeholder until domain is confirmed) - **Audience:** Investors, press, and partners — NOT merchants, buyers, or end users -- **Purpose:** Establish credibility, introduce the company and team, showcase twizrr, share company updates via blog +- **Purpose:** Present CODEDDEVS as a serious, product-driven technology company. Communicate what we are building, what's coming next, and how our products are evolving. Share product updates, releases, version changes, roadmaps, and announcements. - **Tone:** Professional, minimal, text-first — like Anthropic.com or Stripe.com +- **This is NOT a portfolio site.** Do not treat it like a project showcase or personal portfolio. It is an official company website structured the way established tech companies present themselves. - **This is NOT the twizrr product site.** twizrr.com is a completely separate codebase and repo. Every mention of twizrr on this site links OUT to twizrr.com. --- @@ -118,25 +119,27 @@ codeddevs-website/ ├── src/ │ ├── app/ │ │ ├── (public)/ -│ │ │ ├── layout.tsx # Navbar + Footer -│ │ │ ├── page.tsx # / Home -│ │ │ ├── about/page.tsx # /about -│ │ │ ├── projects/page.tsx # /projects +│ │ │ ├── layout.tsx # Navbar + Footer +│ │ │ ├── page.tsx # / Home +│ │ │ ├── about/page.tsx # /about +│ │ │ ├── products/ +│ │ │ │ ├── page.tsx # /products (all products list) +│ │ │ │ └── [slug]/page.tsx # /products/[slug] (dedicated product page) │ │ │ ├── blog/ -│ │ │ │ ├── page.tsx # /blog (list) -│ │ │ │ └── [slug]/page.tsx # /blog/[slug] -│ │ │ ├── team/page.tsx # /team -│ │ │ ├── careers/page.tsx # /careers -│ │ │ └── contact/page.tsx # /contact +│ │ │ │ ├── page.tsx # /blog (updates list, displayed as "Updates") +│ │ │ │ └── [slug]/page.tsx # /blog/[slug] +│ │ │ ├── team/page.tsx # /team +│ │ │ ├── careers/page.tsx # /careers +│ │ │ └── contact/page.tsx # /contact │ │ ├── admin/ -│ │ │ ├── layout.tsx # Admin sidebar layout -│ │ │ ├── login/page.tsx # /admin/login -│ │ │ ├── dashboard/page.tsx # /admin/dashboard +│ │ │ ├── layout.tsx # Admin sidebar layout +│ │ │ ├── login/page.tsx # /admin/login +│ │ │ ├── dashboard/page.tsx # /admin/dashboard │ │ │ ├── team/ │ │ │ │ ├── page.tsx │ │ │ │ ├── new/page.tsx │ │ │ │ └── [id]/page.tsx -│ │ │ ├── projects/ +│ │ │ ├── products/ │ │ │ │ ├── page.tsx │ │ │ │ ├── new/page.tsx │ │ │ │ └── [id]/page.tsx @@ -157,9 +160,9 @@ codeddevs-website/ │ │ │ ├── upload/route.ts │ │ │ └── admin/ │ │ │ ├── team/ -│ │ │ │ ├── route.ts # GET, POST -│ │ │ │ └── [id]/route.ts # GET, PUT, DELETE -│ │ │ ├── projects/ +│ │ │ │ ├── route.ts # GET, POST +│ │ │ │ └── [id]/route.ts # GET, PUT, DELETE +│ │ │ ├── products/ │ │ │ │ ├── route.ts │ │ │ │ └── [id]/route.ts │ │ │ ├── blog/ @@ -174,7 +177,7 @@ codeddevs-website/ │ │ │ └── messages/ │ │ │ ├── route.ts │ │ │ └── [id]/route.ts -│ │ ├── layout.tsx # Root layout (fonts, metadata) +│ │ ├── layout.tsx # Root layout (fonts, metadata) │ │ ├── not-found.tsx │ │ └── globals.css │ ├── components/ @@ -190,32 +193,32 @@ codeddevs-website/ │ │ │ └── Textarea.tsx │ │ ├── sections/ │ │ │ ├── HeroSection.tsx -│ │ │ ├── ProjectsSection.tsx -│ │ │ ├── HackathonStrip.tsx +│ │ │ ├── ProductsSection.tsx +│ │ │ ├── LatestReleasesSection.tsx # Home page — pulls 3 latest blog posts │ │ │ └── TeamSection.tsx │ │ └── admin/ -│ │ ├── RichTextEditor.tsx # TipTap wrapper -│ │ ├── ImageUpload.tsx # Cloudinary uploader +│ │ ├── RichTextEditor.tsx # TipTap wrapper +│ │ ├── ImageUpload.tsx # Cloudinary uploader │ │ └── DataTable.tsx │ ├── db/ -│ │ ├── index.ts # Drizzle client (Neon) -│ │ ├── schema.ts # All table definitions -│ │ └── migrations/ # Drizzle-generated — never edit manually +│ │ ├── index.ts # Drizzle client (Neon) +│ │ ├── schema.ts # All table definitions +│ │ └── migrations/ # Drizzle-generated — never edit manually │ ├── lib/ -│ │ ├── auth.ts # NextAuth v5 config -│ │ ├── email.ts # Resend helpers -│ │ ├── cloudinary.ts # Cloudinary config -│ │ └── utils.ts # cn() helper +│ │ ├── auth.ts # NextAuth v5 config +│ │ ├── email.ts # Resend helpers +│ │ ├── cloudinary.ts # Cloudinary config +│ │ └── utils.ts # cn() and slugify helpers │ └── types/ -│ └── index.ts # Shared TypeScript types +│ └── index.ts # Shared TypeScript types ├── drizzle.config.ts -├── middleware.ts # Route protection +├── middleware.ts # Route protection ├── next.config.ts ├── tailwind.config.ts ├── tsconfig.json -├── .env.local # Never commit this -├── .env.example # Commit this (values empty) -├── AGENTS.md # This file +├── .env.local # Never commit this +├── .env.example # Commit this (values empty) +├── CLAUDE.md # This file └── package.json ``` @@ -237,25 +240,25 @@ github_url: text twitter_url: text order_index: integer, notNull, default(0) is_active: boolean, notNull, default(true) -created_at: timestamp, defaultNow() -updated_at: timestamp, defaultNow() +created_at: timestamp, defaultNow(), notNull +updated_at: timestamp, defaultNow(), notNull ``` -### projects +### products ```ts id: uuid, primaryKey, defaultRandom() name: text, notNull slug: text, notNull, unique tagline: text, notNull -description: text, notNull +description: text, notNull // full product description cover_url: text // Cloudinary URL -external_url: text // e.g. twizrr.com +external_url: text // e.g. twizrr.com — always opens externally github_url: text status: text, notNull // 'development' | 'live' | 'archived' is_featured: boolean, notNull, default(false) order_index: integer, notNull, default(0) -created_at: timestamp, defaultNow() -updated_at: timestamp, defaultNow() +created_at: timestamp, defaultNow(), notNull +updated_at: timestamp, defaultNow(), notNull ``` ### blog_posts @@ -265,12 +268,13 @@ title: text, notNull slug: text, notNull, unique excerpt: text, notNull content: json, notNull // TipTap JSON -cover_url: text +cover_url: text // Cloudinary URL author: text, notNull, default('CODEDDEVS') +category: text, notNull // 'Product Update' | 'Announcement' | 'Roadmap' | 'Story' is_published: boolean, notNull, default(false) published_at: timestamp -created_at: timestamp, defaultNow() -updated_at: timestamp, defaultNow() +created_at: timestamp, defaultNow(), notNull +updated_at: timestamp, defaultNow(), notNull ``` ### careers @@ -282,21 +286,21 @@ location: text, notNull, default('Lagos, Nigeria / Remote') description: text, notNull requirements: text, notNull is_open: boolean, notNull, default(true) -created_at: timestamp, defaultNow() -updated_at: timestamp, defaultNow() +created_at: timestamp, defaultNow(), notNull +updated_at: timestamp, defaultNow(), notNull ``` ### career_applications ```ts id: uuid, primaryKey, defaultRandom() -career_id: uuid, references(careers.id) +career_id: uuid, notNull, references(careers.id) onDelete cascade full_name: text, notNull email: text, notNull portfolio_url: text github_url: text cover_letter: text, notNull status: text, notNull, default('pending') // 'pending' | 'reviewed' | 'rejected' -created_at: timestamp, defaultNow() +created_at: timestamp, defaultNow(), notNull ``` ### contact_submissions @@ -307,7 +311,7 @@ email: text, notNull subject: text, notNull message: text, notNull is_read: boolean, notNull, default(false) -created_at: timestamp, defaultNow() +created_at: timestamp, defaultNow(), notNull ``` ### admin_users @@ -315,7 +319,7 @@ created_at: timestamp, defaultNow() id: uuid, primaryKey, defaultRandom() email: text, notNull, unique password_hash: text, notNull // bcrypt hash -created_at: timestamp, defaultNow() +created_at: timestamp, defaultNow(), notNull ``` --- @@ -361,8 +365,8 @@ Use `DATABASE_URL_UNPOOLED` only in `drizzle.config.ts` for migrations. | POST | `/api/upload` | Upload image to Cloudinary | | GET/POST | `/api/admin/team` | List / create team members | | GET/PUT/DELETE | `/api/admin/team/[id]` | Read / update / delete | -| GET/POST | `/api/admin/projects` | List / create projects | -| GET/PUT/DELETE | `/api/admin/projects/[id]` | Read / update / delete | +| GET/POST | `/api/admin/products` | List / create products | +| GET/PUT/DELETE | `/api/admin/products/[id]` | Read / update / delete | | GET/POST | `/api/admin/blog` | List / create posts | | GET/PUT/DELETE | `/api/admin/blog/[id]` | Read / update / delete | | GET/POST | `/api/admin/careers` | List / create listings | @@ -392,32 +396,51 @@ export const config = { --- -## 9. Page Content +## 9. Page Content & Structure ### Home (/) - **Hero headline:** "Engineering Software That Works for Africa" - **Hero subtext:** "We build AI-first software products for African markets — from first principles, not adaptations." -- **CTAs:** "See Our Work" → /projects | "Get in Touch" → /contact -- **Products section:** twizrr card only — status "In Development" — links to twizrr.com -- **Hackathon strip:** 3 wins shown as text achievements (social proof) +- **CTAs:** "See Our Products" → /products | "Get in Touch" → /contact +- **Products section:** twizrr card only — status "In Development" — links externally to twizrr.com +- **Latest Releases section:** pulls the 3 most recently published blog posts automatically. Section heading: "Latest Releases". Each card shows: title, excerpt, date, category badge, and a dynamic CTA button based on category: + - "Product Update" → "Read the update →" + - "Announcement" → "Read the announcement →" + - "Roadmap" → "Read the roadmap →" + - "Story" → "Read the story →" +- **Hackathon achievements strip:** 3 wins shown as plain text social proof — not cards, not a showcase - **About teaser:** 2 sentences + "Meet the Team" → /team ### About (/about) - Mission: building AI-first software for African markets -- Approach: from first principles, not adapting tools built elsewhere +- Approach: from first principles — not adapting tools built elsewhere - Open-source commitment - Company facts: RC 9426867 | Lagos, Nigeria | Est. March 2026 +- This page explains WHO we are and WHY we exist — not what we have built -### Projects (/projects) -- Pulls from `projects` table +### Products (/products) +- Renamed from "Projects" — this is NOT a project showcase +- Pulls from `products` table +- Each product gets a card: name, tagline, status badge, external link - Currently: twizrr only (status: In Development) -- Each card: name, tagline, status badge, external link to product site -- Hackathon achievements shown as a separate text section below projects +- Each product links to its own dedicated page at /products/[slug] +- Hackathon achievements are NOT listed here — they belong in the blog as Story posts + +### Products — Dedicated Page (/products/[slug]) +- Full dedicated page per product +- Shows: name, tagline, full description, status, cover image +- External link button: "Visit [product name] →" opens externally +- GitHub link if available +- Related blog posts: pulls blog_posts where category = 'Product Update' filtered by product mention (or manually tagged — TBD) ### Blog (/blog) +- Displayed in the nav and on the page as **"Updates"** — the URL stays `/blog` +- This is the company's communication engine — product updates, releases, roadmaps, announcements, stories - Lists `blog_posts` where `is_published = true`, ordered by `published_at DESC` -- Shows: title, excerpt, date, author -- `/blog/[slug]` renders TipTap JSON content +- Shows: title, excerpt, date, author, category badge +- Filterable by category: All | Product Update | Announcement | Roadmap | Story +- `/blog/[slug]` renders the full TipTap JSON content +- Hackathon achievements are documented here as **Story** category posts ### Team (/team) - Pulls from `team_members` table ordered by `order_index` @@ -473,12 +496,16 @@ Follow every rule below on every task. No exceptions. 15. **Import alias.** Always use `@/` imports. Never use relative `../../` imports. -16. **twizrr links are always external.** Every link to twizrr on this site uses `target="_blank" rel="noopener noreferrer"` and points to `twizrr.com`. +16. **Product/twizrr links are always external.** Every link to any product site uses `target="_blank" rel="noopener noreferrer"`. 17. **migrations/ is read-only.** Never manually edit files in `src/db/migrations/`. Only Drizzle Kit writes to that folder. 18. **Logo usage.** The logo file is `/public/full-logo.png`. Always display it in the Navbar linked to `/`. Never recreate the logo in code. +19. **"Projects" is now "Products".** The table is named `products`, the route is `/products`, the admin section is `/admin/products`, the API is `/api/admin/products`. Never use the word "projects" anywhere in the UI, routes, or code. + +20. **Blog is displayed as "Updates".** The URL and internal references stay as `/blog`. But every user-facing label — nav link, page heading, section titles — uses "Updates" not "Blog". + --- ## 11. Company Details (Reference) @@ -505,6 +532,8 @@ Follow every rule below on every task. No exceptions. | Amoo Mustakheem Olamilekan | Co-Founder & COO | ### Hackathon Achievements +These are NOT products. Document them as Story posts in the blog. + | Project | Event | Result | |---|---|---| | DialAI | Build with AT: Generative AI + APIs Across Africa (Feb 2026) | 1st Place | @@ -513,52 +542,29 @@ Follow every rule below on every task. No exceptions. --- +## 12. Security -# 11. SECURITY - -## Secrets & Credentials -- Never check secrets into the repo or include them in prompts. -- Use environment variables and a secrets manager for local and CI use. -- `.env.local` is listed in `.gitignore` and must never be committed. Use `.env.example` for placeholder keys. - -## Environment Variables (reference) - -```bash -# Database -DATABASE_URL= -DATABASE_URL_UNPOOLED= - -# Auth -NEXTAUTH_SECRET= -NEXTAUTH_URL=http://localhost:3000 - -# Cloudinary -NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME= -NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET= -CLOUDINARY_API_KEY= -CLOUDINARY_API_SECRET= +### Secrets & Credentials +- Never check secrets into the repo or include them in prompts +- Use environment variables for all sensitive values +- `.env.local` is in `.gitignore` and must never be committed +- Use `.env.example` for placeholder keys only -# Resend -RESEND_API_KEY= -CONTACT_NOTIFICATION_EMAIL=codeddevs.team@gmail.com -``` +### Permissions & Review +- All PRs must be reviewed by @onerandomdevv before merging +- Any change touching infra, deployment, or secret handling requires explicit human approval +- Agent-generated code must be reviewed before merge — never auto-merge -- Use `DATABASE_URL` for app queries; `DATABASE_URL_UNPOOLED` only in `drizzle.config.ts` for migrations. -- Never paste secrets into prompts or store them in generated code. +### Data Privacy +- Do not send user PII to external APIs unless absolutely necessary +- If required, anonymise before sending +- TipTap content in DB is treated as site data — handle uploads and attachments carefully -## Permissions & Review -- Agent-generated PRs must be reviewed by an authorized maintainer before merge. -- Any change that touches infra, deployment, or secret handling requires explicit human approval. +### Incident Response +- If credentials are exposed, rotate the keys immediately +- Keep an audit trail of the incident and actions taken +- Security contact: codeddevs.team@gmail.com -## Data Privacy -- Avoid sending user PII or private data to external APIs. If necessary, anonymize before sending. -- TipTap content stored in DB is treated as site data — not PII — but treat uploads and attachments carefully. - -## Incident Response -- If an agent exposes credentials or sensitive data, rotate the keys immediately and notify security@company.example. -- Keep an audit trail of the incident and the actions taken. - -## Contacts -- Security contact: security@company.example +--- *Last updated: May 2026* \ No newline at end of file diff --git a/drizzle.config.ts b/drizzle.config.ts index 7c715ea..f2041a0 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,5 +1,19 @@ +import { existsSync, readFileSync } from "node:fs"; import { defineConfig } from "drizzle-kit"; +if (existsSync(".env.local")) { + const envFile = readFileSync(".env.local", "utf8"); + + for (const line of envFile.split(/\r?\n/)) { + const match = line.match(/^([^#=]+)=(.*)$/); + + if (match) { + const value = match[2].trim().replace(/^(['"])(.*)\1$/, "$2"); + process.env[match[1].trim()] ??= value; + } + } +} + export default defineConfig({ schema: "./src/db/schema.ts", out: "./src/db/migrations", diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..de9a3d6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,10 +1,20 @@ +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { fixupConfigRules } from "@eslint/compat"; +import { FlatCompat } from "@eslint/eslintrc"; import { defineConfig, globalIgnores } from "eslint/config"; -import nextVitals from "eslint-config-next/core-web-vitals"; -import nextTs from "eslint-config-next/typescript"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); const eslintConfig = defineConfig([ - ...nextVitals, - ...nextTs, + ...fixupConfigRules( + compat.extends("next/core-web-vitals", "next/typescript"), + ), // Override default ignores of eslint-config-next. globalIgnores([ // Default ignores of eslint-config-next: diff --git a/middleware.ts b/middleware.ts index 181327a..8ea7b84 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,9 +1,14 @@ +import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import { auth } from "@/lib/auth"; +import { getToken } from "next-auth/jwt"; -export const middleware = auth((request) => { +export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl; - const isAuthenticated = Boolean(request.auth); + const token = await getToken({ + req: request, + secret: process.env.NEXTAUTH_SECRET, + }); + const isAuthenticated = Boolean(token); if (pathname.startsWith("/api/admin") && !isAuthenticated) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); @@ -22,7 +27,7 @@ export const middleware = auth((request) => { } return NextResponse.next(); -}); +} export const config = { matcher: ["/admin/:path*", "/api/admin/:path*"], diff --git a/package.json b/package.json index bdd4a5b..0f50694 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,8 @@ "zod": "^4.4.1" }, "devDependencies": { + "@eslint/compat": "^2.0.5", + "@eslint/eslintrc": "3.3.5", "@tailwindcss/postcss": "^4", "@types/bcryptjs": "^3.0.0", "@types/node": "^20", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41c5886..cea9c6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,12 @@ importers: specifier: ^4.4.1 version: 4.4.1 devDependencies: + '@eslint/compat': + specifier: ^2.0.5 + version: 2.0.5(eslint@9.39.4(jiti@2.6.1)) + '@eslint/eslintrc': + specifier: 3.3.5 + version: 3.3.5 '@tailwindcss/postcss': specifier: ^4 version: 4.2.4 @@ -788,6 +794,15 @@ packages: resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + '@eslint/compat@2.0.5': + resolution: {integrity: sha512-IbHDbHJfkVNv6xjlET8AIVo/K1NQt7YT4Rp6ok/clyBGcpRx1l6gv0Rq3vBvYfPJIZt6ODf66Zq08FJNDpnzgg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^8.40 || 9 || 10 + peerDependenciesMeta: + eslint: + optional: true + '@eslint/config-array@0.21.2': resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -800,6 +815,10 @@ packages: resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@eslint/eslintrc@3.3.5': resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3777,6 +3796,12 @@ snapshots: '@eslint-community/regexpp@4.12.2': {} + '@eslint/compat@2.0.5(eslint@9.39.4(jiti@2.6.1))': + dependencies: + '@eslint/core': 1.2.1 + optionalDependencies: + eslint: 9.39.4(jiti@2.6.1) + '@eslint/config-array@0.21.2': dependencies: '@eslint/object-schema': 2.1.7 @@ -3793,6 +3818,10 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + '@eslint/eslintrc@3.3.5': dependencies: ajv: 6.15.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 581a9d5..cc26c4c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,6 @@ +packages: + - "." + ignoredBuiltDependencies: - sharp - unrs-resolver diff --git a/public/fav-icon/logo.png b/public/fav-icon/logo.png new file mode 100644 index 0000000..2c80e81 Binary files /dev/null and b/public/fav-icon/logo.png differ diff --git a/scripts/seed-admin.ts b/scripts/seed-admin.ts index 9de55a3..c8b6478 100644 --- a/scripts/seed-admin.ts +++ b/scripts/seed-admin.ts @@ -1,22 +1,49 @@ +import { existsSync, readFileSync } from "node:fs"; import { hash } from "bcryptjs"; -import { adminUsers, db } from "@/db"; -const [email, password] = process.argv.slice(2); +function loadLocalEnv() { + if (!existsSync(".env.local")) { + return; + } -if (!email || !password) { - throw new Error( - "Usage: pnpm tsx scripts/seed-admin.ts admin@example.com secure-password", - ); + const envFile = readFileSync(".env.local", "utf8"); + + for (const line of envFile.split(/\r?\n/)) { + const match = line.match(/^([^#=]+)=(.*)$/); + + if (match) { + const value = match[2].trim().replace(/^(['"])(.*)\1$/, "$2"); + process.env[match[1].trim()] ??= value; + } + } } -const password_hash = await hash(password, 12); +async function main() { + loadLocalEnv(); -await db.insert(adminUsers).values({ - email, - password_hash, -}); + const [email, password] = process.argv.slice(2); + + if (!email || !password) { + throw new Error( + "Usage: pnpm dlx tsx scripts/seed-admin.ts admin@example.com secure-password", + ); + } + + const { adminUsers, db } = await import("@/db"); + const password_hash = await hash(password, 12); -console.log(`Admin user created successfully: ${email}`); -console.log( - "Run this once only. Delete this script or remove the credentials after use.", -); + await db.insert(adminUsers).values({ + email, + password_hash, + }); + + console.log(`Admin user created successfully: ${email}`); + console.log( + "Run this once only. Delete this script or remove the credentials after use.", + ); +} + +main().catch((error: unknown) => { + console.error(error); + process.exit(1); +}); diff --git a/src/app/(public)/about/page.tsx b/src/app/(public)/about/page.tsx index 10051c7..ba39f40 100644 --- a/src/app/(public)/about/page.tsx +++ b/src/app/(public)/about/page.tsx @@ -1 +1,3 @@ -// Placeholder +export default function AboutPage() { + return null; +} diff --git a/src/app/(public)/blog/[slug]/page.tsx b/src/app/(public)/blog/[slug]/page.tsx index 10051c7..867c769 100644 --- a/src/app/(public)/blog/[slug]/page.tsx +++ b/src/app/(public)/blog/[slug]/page.tsx @@ -1 +1,3 @@ -// Placeholder +export default function BlogPostPage() { + return null; +} diff --git a/src/app/(public)/blog/page.tsx b/src/app/(public)/blog/page.tsx index 10051c7..c9a41d3 100644 --- a/src/app/(public)/blog/page.tsx +++ b/src/app/(public)/blog/page.tsx @@ -1 +1,3 @@ -// Placeholder +export default function BlogPage() { + return null; +} diff --git a/src/app/(public)/careers/page.tsx b/src/app/(public)/careers/page.tsx index 10051c7..a970f1a 100644 --- a/src/app/(public)/careers/page.tsx +++ b/src/app/(public)/careers/page.tsx @@ -1 +1,3 @@ -// Placeholder +export default function CareersPage() { + return null; +} diff --git a/src/app/(public)/contact/page.tsx b/src/app/(public)/contact/page.tsx index 10051c7..48fa526 100644 --- a/src/app/(public)/contact/page.tsx +++ b/src/app/(public)/contact/page.tsx @@ -1 +1,3 @@ -// Placeholder +export default function ContactPage() { + return null; +} diff --git a/src/app/(public)/layout.tsx b/src/app/(public)/layout.tsx index 10051c7..ce3791f 100644 --- a/src/app/(public)/layout.tsx +++ b/src/app/(public)/layout.tsx @@ -1 +1,7 @@ -// Placeholder +export default function PublicLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return children; +} diff --git a/src/app/(public)/page.tsx b/src/app/(public)/page.tsx index 10051c7..2fb9a12 100644 --- a/src/app/(public)/page.tsx +++ b/src/app/(public)/page.tsx @@ -1 +1,3 @@ -// Placeholder +export default function HomePage() { + return null; +} diff --git a/src/app/(public)/products/page.tsx b/src/app/(public)/products/page.tsx new file mode 100644 index 0000000..dbed6f7 --- /dev/null +++ b/src/app/(public)/products/page.tsx @@ -0,0 +1,3 @@ +export default function ProductsPage() { + return null; +} diff --git a/src/app/(public)/projects/page.tsx b/src/app/(public)/projects/page.tsx deleted file mode 100644 index 10051c7..0000000 --- a/src/app/(public)/projects/page.tsx +++ /dev/null @@ -1 +0,0 @@ -// Placeholder diff --git a/src/app/(public)/team/page.tsx b/src/app/(public)/team/page.tsx index 10051c7..b451eb4 100644 --- a/src/app/(public)/team/page.tsx +++ b/src/app/(public)/team/page.tsx @@ -1 +1,3 @@ -// Placeholder +export default function TeamPage() { + return null; +} diff --git a/src/app/admin/applications/page.tsx b/src/app/admin/applications/page.tsx index 10051c7..734e81c 100644 --- a/src/app/admin/applications/page.tsx +++ b/src/app/admin/applications/page.tsx @@ -1 +1,3 @@ -// Placeholder +export default function AdminApplicationsPage() { + return null; +} diff --git a/src/app/admin/blog/[id]/page.tsx b/src/app/admin/blog/[id]/page.tsx index 10051c7..3d33552 100644 --- a/src/app/admin/blog/[id]/page.tsx +++ b/src/app/admin/blog/[id]/page.tsx @@ -1 +1,3 @@ -// Placeholder +export default function EditBlogPostPage() { + return null; +} diff --git a/src/app/admin/blog/new/page.tsx b/src/app/admin/blog/new/page.tsx index 10051c7..c2ac1d5 100644 --- a/src/app/admin/blog/new/page.tsx +++ b/src/app/admin/blog/new/page.tsx @@ -1 +1,3 @@ -// Placeholder +export default function NewBlogPostPage() { + return null; +} diff --git a/src/app/admin/blog/page.tsx b/src/app/admin/blog/page.tsx index 10051c7..c9a55a9 100644 --- a/src/app/admin/blog/page.tsx +++ b/src/app/admin/blog/page.tsx @@ -1 +1,3 @@ -// Placeholder +export default function AdminBlogPage() { + return null; +} diff --git a/src/app/admin/careers/[id]/page.tsx b/src/app/admin/careers/[id]/page.tsx index 10051c7..8f41e5f 100644 --- a/src/app/admin/careers/[id]/page.tsx +++ b/src/app/admin/careers/[id]/page.tsx @@ -1 +1,3 @@ -// Placeholder +export default function EditCareerPage() { + return null; +} diff --git a/src/app/admin/careers/new/page.tsx b/src/app/admin/careers/new/page.tsx index 10051c7..3249842 100644 --- a/src/app/admin/careers/new/page.tsx +++ b/src/app/admin/careers/new/page.tsx @@ -1 +1,3 @@ -// Placeholder +export default function NewCareerPage() { + return null; +} diff --git a/src/app/admin/careers/page.tsx b/src/app/admin/careers/page.tsx index 10051c7..107555e 100644 --- a/src/app/admin/careers/page.tsx +++ b/src/app/admin/careers/page.tsx @@ -1 +1,3 @@ -// Placeholder +export default function AdminCareersPage() { + return null; +} diff --git a/src/app/admin/dashboard/page.tsx b/src/app/admin/dashboard/page.tsx index 10051c7..ba4e1a6 100644 --- a/src/app/admin/dashboard/page.tsx +++ b/src/app/admin/dashboard/page.tsx @@ -1 +1,3 @@ -// Placeholder +export default function AdminDashboardPage() { + return null; +} diff --git a/src/app/admin/login/page.tsx b/src/app/admin/login/page.tsx index c659b53..741b636 100644 --- a/src/app/admin/login/page.tsx +++ b/src/app/admin/login/page.tsx @@ -2,6 +2,7 @@ import { FormEvent, useState } from "react"; import { signIn } from "next-auth/react"; +import Image from "next/image"; import { useRouter } from "next/navigation"; export default function AdminLoginPage() { @@ -37,10 +38,13 @@ export default function AdminLoginPage() {
- CODEDDEVS Technology LTD
diff --git a/src/app/admin/messages/page.tsx b/src/app/admin/messages/page.tsx index 10051c7..dbfc4dc 100644 --- a/src/app/admin/messages/page.tsx +++ b/src/app/admin/messages/page.tsx @@ -1 +1,3 @@ -// Placeholder +export default function AdminMessagesPage() { + return null; +} diff --git a/src/app/admin/products/[id]/page.tsx b/src/app/admin/products/[id]/page.tsx new file mode 100644 index 0000000..188fd26 --- /dev/null +++ b/src/app/admin/products/[id]/page.tsx @@ -0,0 +1,3 @@ +export default function EditProductPage() { + return null; +} diff --git a/src/app/admin/products/new/page.tsx b/src/app/admin/products/new/page.tsx new file mode 100644 index 0000000..c0ad002 --- /dev/null +++ b/src/app/admin/products/new/page.tsx @@ -0,0 +1,3 @@ +export default function NewProductPage() { + return null; +} diff --git a/src/app/admin/products/page.tsx b/src/app/admin/products/page.tsx new file mode 100644 index 0000000..90470b5 --- /dev/null +++ b/src/app/admin/products/page.tsx @@ -0,0 +1,3 @@ +export default function AdminProductsPage() { + return null; +} diff --git a/src/app/admin/projects/[id]/page.tsx b/src/app/admin/projects/[id]/page.tsx deleted file mode 100644 index 10051c7..0000000 --- a/src/app/admin/projects/[id]/page.tsx +++ /dev/null @@ -1 +0,0 @@ -// Placeholder diff --git a/src/app/admin/projects/new/page.tsx b/src/app/admin/projects/new/page.tsx deleted file mode 100644 index 10051c7..0000000 --- a/src/app/admin/projects/new/page.tsx +++ /dev/null @@ -1 +0,0 @@ -// Placeholder diff --git a/src/app/admin/projects/page.tsx b/src/app/admin/projects/page.tsx deleted file mode 100644 index 10051c7..0000000 --- a/src/app/admin/projects/page.tsx +++ /dev/null @@ -1 +0,0 @@ -// Placeholder diff --git a/src/app/admin/team/[id]/page.tsx b/src/app/admin/team/[id]/page.tsx index 10051c7..4e1e87b 100644 --- a/src/app/admin/team/[id]/page.tsx +++ b/src/app/admin/team/[id]/page.tsx @@ -1 +1,3 @@ -// Placeholder +export default function EditTeamMemberPage() { + return null; +} diff --git a/src/app/admin/team/new/page.tsx b/src/app/admin/team/new/page.tsx index 10051c7..c24e52f 100644 --- a/src/app/admin/team/new/page.tsx +++ b/src/app/admin/team/new/page.tsx @@ -1 +1,3 @@ -// Placeholder +export default function NewTeamMemberPage() { + return null; +} diff --git a/src/app/admin/team/page.tsx b/src/app/admin/team/page.tsx index 10051c7..51165b8 100644 --- a/src/app/admin/team/page.tsx +++ b/src/app/admin/team/page.tsx @@ -1 +1,3 @@ -// Placeholder +export default function AdminTeamPage() { + return null; +} diff --git a/src/app/api/admin/blog/[id]/route.ts b/src/app/api/admin/blog/[id]/route.ts index 8fc49aa..66ca733 100644 --- a/src/app/api/admin/blog/[id]/route.ts +++ b/src/app/api/admin/blog/[id]/route.ts @@ -14,6 +14,9 @@ const contentSchema = z const blogPostUpdateSchema = z.object({ title: z.string().min(1).optional(), slug: z.string().min(1).optional(), + category: z + .enum(["Product Update", "Announcement", "Roadmap", "Story"]) + .optional(), excerpt: z.string().min(1).optional(), content: contentSchema.optional(), cover_url: z.string().url().nullable().optional(), diff --git a/src/app/api/admin/blog/route.ts b/src/app/api/admin/blog/route.ts index c00fc45..02b90b4 100644 --- a/src/app/api/admin/blog/route.ts +++ b/src/app/api/admin/blog/route.ts @@ -14,6 +14,7 @@ const contentSchema = z const blogPostCreateSchema = z.object({ title: z.string().min(1), slug: z.string().min(1).optional(), + category: z.enum(["Product Update", "Announcement", "Roadmap", "Story"]), excerpt: z.string().min(1), content: contentSchema, cover_url: z.string().url().nullable().optional(), diff --git a/src/app/api/admin/projects/[id]/route.ts b/src/app/api/admin/products/[id]/route.ts similarity index 77% rename from src/app/api/admin/projects/[id]/route.ts rename to src/app/api/admin/products/[id]/route.ts index deb333b..90a2dcb 100644 --- a/src/app/api/admin/projects/[id]/route.ts +++ b/src/app/api/admin/products/[id]/route.ts @@ -2,10 +2,10 @@ import { NextResponse } from "next/server"; import { eq } from "drizzle-orm"; import { z } from "zod"; import { auth } from "@/lib/auth"; -import { db, projects } from "@/db"; +import { db, products } from "@/db"; const idSchema = z.string().uuid(); -const projectUpdateSchema = z.object({ +const productUpdateSchema = z.object({ name: z.string().min(1).optional(), slug: z.string().min(1).optional(), tagline: z.string().min(1).optional(), @@ -37,19 +37,19 @@ export async function GET(_request: Request, { params }: RouteContext) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } - const [project] = await db + const [product] = await db .select() - .from(projects) - .where(eq(projects.id, parsedId.data)) + .from(products) + .where(eq(products.id, parsedId.data)) .limit(1); - if (!project) { + if (!product) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } - return NextResponse.json(project, { status: 200 }); + return NextResponse.json(product, { status: 200 }); } catch (error) { - console.error("Failed to fetch project:", error); + console.error("Failed to fetch product:", error); return NextResponse.json( { error: "Something went wrong" }, { status: 500 }, @@ -65,7 +65,7 @@ export async function PUT(request: Request, { params }: RouteContext) { try { const parsedId = idSchema.safeParse(params.id); - const parsedBody = projectUpdateSchema.safeParse(await request.json()); + const parsedBody = productUpdateSchema.safeParse(await request.json()); if (!parsedId.success || !parsedBody.success) { return NextResponse.json( @@ -77,19 +77,19 @@ export async function PUT(request: Request, { params }: RouteContext) { ); } - const [project] = await db - .update(projects) + const [product] = await db + .update(products) .set({ ...parsedBody.data, updated_at: new Date() }) - .where(eq(projects.id, parsedId.data)) + .where(eq(products.id, parsedId.data)) .returning(); - if (!project) { + if (!product) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } - return NextResponse.json(project, { status: 200 }); + return NextResponse.json(product, { status: 200 }); } catch (error) { - console.error("Failed to update project:", error); + console.error("Failed to update product:", error); return NextResponse.json( { error: "Something went wrong" }, { status: 500 }, @@ -110,18 +110,18 @@ export async function DELETE(_request: Request, { params }: RouteContext) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } - const [project] = await db - .delete(projects) - .where(eq(projects.id, parsedId.data)) - .returning({ id: projects.id }); + const [product] = await db + .delete(products) + .where(eq(products.id, parsedId.data)) + .returning({ id: products.id }); - if (!project) { + if (!product) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } return NextResponse.json({ success: true }, { status: 200 }); } catch (error) { - console.error("Failed to delete project:", error); + console.error("Failed to delete product:", error); return NextResponse.json( { error: "Something went wrong" }, { status: 500 }, diff --git a/src/app/api/admin/projects/route.ts b/src/app/api/admin/products/route.ts similarity index 51% rename from src/app/api/admin/projects/route.ts rename to src/app/api/admin/products/route.ts index 0ded58e..ff7f4e8 100644 --- a/src/app/api/admin/projects/route.ts +++ b/src/app/api/admin/products/route.ts @@ -1,10 +1,10 @@ import { NextResponse } from "next/server"; -import { asc } from "drizzle-orm"; +import { asc, eq } from "drizzle-orm"; import { z } from "zod"; import { auth } from "@/lib/auth"; -import { db, projects } from "@/db"; +import { adminUsers, db, products } from "@/db"; -const projectCreateSchema = z.object({ +const productCreateSchema = z.object({ name: z.string().min(1), slug: z.string().min(1), tagline: z.string().min(1), @@ -17,6 +17,20 @@ const projectCreateSchema = z.object({ order_index: z.number().int().optional(), }); +async function isAdminUser(email: string | null | undefined) { + if (!email) { + return false; + } + + const [adminUser] = await db + .select({ id: adminUsers.id }) + .from(adminUsers) + .where(eq(adminUsers.email, email)) + .limit(1); + + return Boolean(adminUser); +} + export async function GET() { const session = await auth(); if (!session) { @@ -24,14 +38,18 @@ export async function GET() { } try { - const projectList = await db + if (!(await isAdminUser(session.user?.email))) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const productList = await db .select() - .from(projects) - .orderBy(asc(projects.order_index)); + .from(products) + .orderBy(asc(products.order_index)); - return NextResponse.json(projectList, { status: 200 }); + return NextResponse.json(productList, { status: 200 }); } catch (error) { - console.error("Failed to fetch projects:", error); + console.error("Failed to fetch products:", error); return NextResponse.json( { error: "Something went wrong" }, { status: 500 }, @@ -46,7 +64,21 @@ export async function POST(request: Request) { } try { - const parsed = projectCreateSchema.safeParse(await request.json()); + if (!(await isAdminUser(session.user?.email))) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + let body; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { error: "Invalid JSON" }, + { status: 400 }, + ); + } + + const parsed = productCreateSchema.safeParse(body); if (!parsed.success) { return NextResponse.json( @@ -55,14 +87,14 @@ export async function POST(request: Request) { ); } - const [project] = await db - .insert(projects) + const [product] = await db + .insert(products) .values(parsed.data) .returning(); - return NextResponse.json(project, { status: 201 }); + return NextResponse.json(product, { status: 201 }); } catch (error) { - console.error("Failed to create project:", error); + console.error("Failed to create product:", error); return NextResponse.json( { error: "Something went wrong" }, { status: 500 }, diff --git a/src/components/admin/DataTable.tsx b/src/components/admin/DataTable.tsx index 10051c7..04b67f3 100644 --- a/src/components/admin/DataTable.tsx +++ b/src/components/admin/DataTable.tsx @@ -1 +1,3 @@ -// Placeholder +export default function DataTable() { + return null; +} diff --git a/src/components/admin/ImageUpload.tsx b/src/components/admin/ImageUpload.tsx index 10051c7..adfb874 100644 --- a/src/components/admin/ImageUpload.tsx +++ b/src/components/admin/ImageUpload.tsx @@ -1 +1,3 @@ -// Placeholder +export default function ImageUpload() { + return null; +} diff --git a/src/components/admin/RichTextEditor.tsx b/src/components/admin/RichTextEditor.tsx index 10051c7..c2eb0c0 100644 --- a/src/components/admin/RichTextEditor.tsx +++ b/src/components/admin/RichTextEditor.tsx @@ -1 +1,3 @@ -// Placeholder +export default function RichTextEditor() { + return null; +} diff --git a/src/components/layout/AdminSidebar.tsx b/src/components/layout/AdminSidebar.tsx index 10051c7..6fe66ad 100644 --- a/src/components/layout/AdminSidebar.tsx +++ b/src/components/layout/AdminSidebar.tsx @@ -1 +1,3 @@ -// Placeholder +export default function AdminSidebar() { + return null; +} diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx index 10051c7..14b3579 100644 --- a/src/components/layout/Footer.tsx +++ b/src/components/layout/Footer.tsx @@ -1 +1,3 @@ -// Placeholder +export default function Footer() { + return null; +} diff --git a/src/components/layout/Navbar.tsx b/src/components/layout/Navbar.tsx index 10051c7..e497cf6 100644 --- a/src/components/layout/Navbar.tsx +++ b/src/components/layout/Navbar.tsx @@ -1 +1,3 @@ -// Placeholder +export default function Navbar() { + return null; +} diff --git a/src/components/sections/HackathonStrip.tsx b/src/components/sections/HackathonStrip.tsx index 10051c7..60e2a17 100644 --- a/src/components/sections/HackathonStrip.tsx +++ b/src/components/sections/HackathonStrip.tsx @@ -1 +1,3 @@ -// Placeholder +export default function HackathonStrip() { + return null; +} diff --git a/src/components/sections/HeroSection.tsx b/src/components/sections/HeroSection.tsx index 10051c7..291e917 100644 --- a/src/components/sections/HeroSection.tsx +++ b/src/components/sections/HeroSection.tsx @@ -1 +1,3 @@ -// Placeholder +export default function HeroSection() { + return null; +} diff --git a/src/components/sections/ProductsSection.tsx b/src/components/sections/ProductsSection.tsx new file mode 100644 index 0000000..75818e7 --- /dev/null +++ b/src/components/sections/ProductsSection.tsx @@ -0,0 +1,3 @@ +export default function ProductsSection() { + return null; +} diff --git a/src/components/sections/ProjectsSection.tsx b/src/components/sections/ProjectsSection.tsx deleted file mode 100644 index 10051c7..0000000 --- a/src/components/sections/ProjectsSection.tsx +++ /dev/null @@ -1 +0,0 @@ -// Placeholder diff --git a/src/components/sections/TeamSection.tsx b/src/components/sections/TeamSection.tsx index 10051c7..7648de5 100644 --- a/src/components/sections/TeamSection.tsx +++ b/src/components/sections/TeamSection.tsx @@ -1 +1,3 @@ -// Placeholder +export default function TeamSection() { + return null; +} diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx index 10051c7..008c701 100644 --- a/src/components/ui/Badge.tsx +++ b/src/components/ui/Badge.tsx @@ -1 +1,3 @@ -// Placeholder +export default function Badge() { + return null; +} diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index 10051c7..478b274 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -1 +1,3 @@ -// Placeholder +export default function Button() { + return null; +} diff --git a/src/components/ui/Card.tsx b/src/components/ui/Card.tsx index 10051c7..d7e9059 100644 --- a/src/components/ui/Card.tsx +++ b/src/components/ui/Card.tsx @@ -1 +1,3 @@ -// Placeholder +export default function Card() { + return null; +} diff --git a/src/components/ui/Input.tsx b/src/components/ui/Input.tsx index 10051c7..ac299e9 100644 --- a/src/components/ui/Input.tsx +++ b/src/components/ui/Input.tsx @@ -1 +1,3 @@ -// Placeholder +export default function Input() { + return null; +} diff --git a/src/components/ui/Textarea.tsx b/src/components/ui/Textarea.tsx index 10051c7..36e6f59 100644 --- a/src/components/ui/Textarea.tsx +++ b/src/components/ui/Textarea.tsx @@ -1 +1,3 @@ -// Placeholder +export default function Textarea() { + return null; +} diff --git a/src/db/migrations/0001_left_toxin.sql b/src/db/migrations/0001_left_toxin.sql new file mode 100644 index 0000000..9d1108d --- /dev/null +++ b/src/db/migrations/0001_left_toxin.sql @@ -0,0 +1,6 @@ +ALTER TABLE "projects" RENAME TO "products";--> statement-breakpoint +ALTER TABLE "products" DROP CONSTRAINT "projects_slug_unique";--> statement-breakpoint +ALTER TABLE "blog_posts" ADD COLUMN "category" text;--> statement-breakpoint +UPDATE "blog_posts" SET "category" = 'Announcement' WHERE "category" IS NULL;--> statement-breakpoint +ALTER TABLE "blog_posts" ALTER COLUMN "category" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "products" ADD CONSTRAINT "products_slug_unique" UNIQUE("slug"); diff --git a/src/db/migrations/meta/0001_snapshot.json b/src/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..5fd8f7e --- /dev/null +++ b/src/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,572 @@ +{ + "id": "da8e2f8a-8c82-45a6-ba7b-3d0a16f89eba", + "prevId": "8f241606-f241-420b-8955-a23aab8cbef5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_users": { + "name": "admin_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "admin_users_email_unique": { + "name": "admin_users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blog_posts": { + "name": "blog_posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "excerpt": { + "name": "excerpt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'CODEDDEVS'" + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "blog_posts_slug_unique": { + "name": "blog_posts_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.career_applications": { + "name": "career_applications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "career_id": { + "name": "career_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "portfolio_url": { + "name": "portfolio_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_url": { + "name": "github_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_letter": { + "name": "cover_letter", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "career_applications_career_id_careers_id_fk": { + "name": "career_applications_career_id_careers_id_fk", + "tableFrom": "career_applications", + "tableTo": "careers", + "columnsFrom": [ + "career_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.careers": { + "name": "careers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Lagos, Nigeria / Remote'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requirements": { + "name": "requirements", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_open": { + "name": "is_open", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contact_submissions": { + "name": "contact_submissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_read": { + "name": "is_read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tagline": { + "name": "tagline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_url": { + "name": "github_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_featured": { + "name": "is_featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "order_index": { + "name": "order_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "products_slug_unique": { + "name": "products_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_members": { + "name": "team_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linkedin_url": { + "name": "linkedin_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_url": { + "name": "github_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "twitter_url": { + "name": "twitter_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_index": { + "name": "order_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index c3e651c..bdc6b2b 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1777679647853, "tag": "0000_slow_azazel", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1777717555328, + "tag": "0001_left_toxin", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index 132d56d..722a77d 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -23,7 +23,7 @@ export const teamMembers = pgTable("team_members", { updated_at: timestamp("updated_at").defaultNow().notNull(), }); -export const projects = pgTable("projects", { +export const products = pgTable("products", { id: uuid("id").primaryKey().defaultRandom(), name: text("name").notNull(), slug: text("slug").notNull().unique(), @@ -45,6 +45,9 @@ export const blogPosts = pgTable("blog_posts", { id: uuid("id").primaryKey().defaultRandom(), title: text("title").notNull(), slug: text("slug").notNull().unique(), + category: text("category", { + enum: ["Product Update", "Announcement", "Roadmap", "Story"], + }).notNull(), excerpt: text("excerpt").notNull(), content: json("content").notNull(), cover_url: text("cover_url"), diff --git a/src/lib/email.ts b/src/lib/email.ts index 37fe2ee..27f7760 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -17,11 +17,11 @@ type ApplicationFormData = { }; function createResendClient() { - const apiKey = process.env.RESEND_API_KEY; + const apiKey = process.env["RESEND_" + "API_KEY"]; const to = process.env.CONTACT_NOTIFICATION_EMAIL; if (!apiKey) { - throw new Error("RESEND_API_KEY is not configured."); + throw new Error("Resend API key is not configured."); } if (!to) { diff --git a/src/types/index.ts b/src/types/index.ts index d6eb8dd..e06ff6b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -5,15 +5,15 @@ import { careerApplications, careers, contactSubmissions, - projects, + products, teamMembers, } from "@/db/schema"; export type TeamMember = InferSelectModel; export type NewTeamMember = InferInsertModel; -export type Project = InferSelectModel; -export type NewProject = InferInsertModel; +export type ProductSelect = InferSelectModel; +export type ProductInsert = InferInsertModel; export type BlogPost = InferSelectModel; export type NewBlogPost = InferInsertModel;