# Wave 1: Content + Reviews + Analytics → Convex Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Migrate the 3 lowest-risk domains (content, reviews, analytics) fully to Convex — including gap-filling missing mutations/queries, wiring all API routes with data-source routing, writing data migration scripts, and flipping to Convex.

**Architecture:** Use the existing `lib/data-source.ts` strangler fig pattern. Each API route gets a `getDataSource(domain) === 'convex'` block that imports Convex functions dynamically. Data migration scripts read from Prisma and bulk-insert into Convex. After validation, flip `DS_<DOMAIN>=convex` in environment.

**Tech Stack:** Convex (mutations/queries/actions), Next.js API routes, existing `lib/convex-client.ts` (ConvexHttpClient), `lib/data-source.ts` (routing), Jest (testing)

**Domain Stats:**
| Domain | Total Routes | Already Wired | Missing Convex Fns | Routes to Wire |
|--------|-------------|---------------|--------------------:|---------------:|
| Reviews | 15 | 3 | ~10 | 12 |
| Content | 4 | 0 | ~7 | 4 |
| Analytics | 7 | 4 | ~12 | 3 |

---

## Task 1: Reviews — Gap-Fill Missing Convex Mutations

**Files:**

- Modify: `convex/reviews/mutations.ts`
- Test: `__tests__/convex/reviews-mutations.test.ts` (create)

**Step 1: Write failing tests for missing mutations**

Create `__tests__/convex/reviews-mutations.test.ts`:

```typescript
import { convexTest } from "convex-test";
import { api } from "../../convex/_generated/api";
import schema from "../../convex/schema";

describe("Reviews mutations - gap fill", () => {
  test("updateReview sets featured status", async () => {
    const t = convexTest(schema);
    // Seed a review
    const reviewId = await t.mutation(api.reviews.mutations.createReview, {
      userId: "user1",
      organizationId: "org1",
      customerName: "Test Customer",
      rating: 5,
      comment: "Great service",
      source: "google",
    });
    // Update to featured
    await t.mutation(api.reviews.mutations.updateReview, {
      id: reviewId,
      featured: true,
      displayOrder: 1,
    });
    const review = await t.query(api.reviews.queries.getReviews, {
      userId: "user1",
    });
    expect(review[0].featured).toBe(true);
    expect(review[0].displayOrder).toBe(1);
  });

  test("createQuestionTemplate stores template", async () => {
    const t = convexTest(schema);
    const id = await t.mutation(api.reviews.mutations.createQuestionTemplate, {
      userId: "user1",
      organizationId: "org1",
      question: "How was the service?",
      questionAr: "كيف كانت الخدمة؟",
      questionType: "rating",
      isRequired: true,
      sortOrder: 0,
    });
    expect(id).toBeDefined();
  });

  test("updateQuestionTemplate modifies fields", async () => {
    const t = convexTest(schema);
    const id = await t.mutation(api.reviews.mutations.createQuestionTemplate, {
      userId: "user1",
      organizationId: "org1",
      question: "Original?",
      questionType: "text",
      isRequired: false,
      sortOrder: 0,
    });
    await t.mutation(api.reviews.mutations.updateQuestionTemplate, {
      id,
      question: "Updated?",
      isRequired: true,
    });
  });

  test("deleteQuestionTemplate removes template", async () => {
    const t = convexTest(schema);
    const id = await t.mutation(api.reviews.mutations.createQuestionTemplate, {
      userId: "user1",
      organizationId: "org1",
      question: "To delete?",
      questionType: "text",
      isRequired: false,
      sortOrder: 0,
    });
    await t.mutation(api.reviews.mutations.deleteQuestionTemplate, { id });
  });

  test("sendReviewRequest updates request status", async () => {
    const t = convexTest(schema);
    const reqId = await t.mutation(api.reviews.mutations.createReviewRequest, {
      userId: "user1",
      organizationId: "org1",
      customerName: "Jane",
      customerEmail: "jane@test.com",
      customerPhone: "+97412345678",
      bookingId: "booking1",
      channel: "email",
    });
    await t.mutation(api.reviews.mutations.sendReviewRequest, {
      id: reqId,
      sentAt: Date.now(),
    });
  });
});
```

**Step 2: Run tests to verify they fail**

Run: `npx jest __tests__/convex/reviews-mutations.test.ts --no-cache`
Expected: FAIL — missing functions `updateReview`, `createQuestionTemplate`, etc.

**Step 3: Implement missing mutations in `convex/reviews/mutations.ts`**

Add to existing file:

```typescript
/**
 * Update a review (featured status, display order, response)
 */
export const updateReview = mutation({
  args: {
    id: v.id("reviews"),
    featured: v.optional(v.boolean()),
    displayOrder: v.optional(v.number()),
    ownerResponse: v.optional(v.string()),
    status: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    const { id, ...updates } = args;
    const existing = await ctx.db.get(id);
    if (!existing) throw new Error(`Review ${id} not found`);
    const patch: Record<string, unknown> = { updatedAt: Date.now() };
    if (updates.featured !== undefined) patch.featured = updates.featured;
    if (updates.displayOrder !== undefined)
      patch.displayOrder = updates.displayOrder;
    if (updates.ownerResponse !== undefined)
      patch.ownerResponse = updates.ownerResponse;
    if (updates.status !== undefined) patch.status = updates.status;
    await ctx.db.patch(id, patch);
    return id;
  },
});

/**
 * Create a review question template
 */
export const createQuestionTemplate = mutation({
  args: {
    userId: v.string(),
    organizationId: v.optional(v.string()),
    question: v.string(),
    questionAr: v.optional(v.string()),
    questionType: v.string(),
    isRequired: v.boolean(),
    sortOrder: v.number(),
    options: v.optional(v.array(v.string())),
  },
  handler: async (ctx, args) => {
    const now = Date.now();
    return await ctx.db.insert("review_question_templates", {
      userId: args.userId,
      organizationId: args.organizationId,
      question: args.question,
      questionAr: args.questionAr,
      questionType: args.questionType as
        | "rating"
        | "text"
        | "multiple_choice"
        | "yes_no",
      isRequired: args.isRequired,
      sortOrder: args.sortOrder,
      options: args.options,
      isActive: true,
      createdAt: now,
      updatedAt: now,
    });
  },
});

/**
 * Update a review question template
 */
export const updateQuestionTemplate = mutation({
  args: {
    id: v.id("review_question_templates"),
    question: v.optional(v.string()),
    questionAr: v.optional(v.string()),
    questionType: v.optional(v.string()),
    isRequired: v.optional(v.boolean()),
    sortOrder: v.optional(v.number()),
    options: v.optional(v.array(v.string())),
    isActive: v.optional(v.boolean()),
  },
  handler: async (ctx, args) => {
    const { id, ...updates } = args;
    const existing = await ctx.db.get(id);
    if (!existing) throw new Error(`Question template ${id} not found`);
    const patch: Record<string, unknown> = { updatedAt: Date.now() };
    for (const [key, value] of Object.entries(updates)) {
      if (value !== undefined) patch[key] = value;
    }
    await ctx.db.patch(id, patch);
    return id;
  },
});

/**
 * Delete a review question template
 */
export const deleteQuestionTemplate = mutation({
  args: { id: v.id("review_question_templates") },
  handler: async (ctx, args) => {
    const existing = await ctx.db.get(args.id);
    if (!existing) throw new Error(`Question template ${args.id} not found`);
    await ctx.db.delete(args.id);
    return args.id;
  },
});

/**
 * Mark a review request as sent
 */
export const sendReviewRequest = mutation({
  args: {
    id: v.id("review_requests"),
    sentAt: v.number(),
  },
  handler: async (ctx, args) => {
    const existing = await ctx.db.get(args.id);
    if (!existing) throw new Error(`Review request ${args.id} not found`);
    await ctx.db.patch(args.id, {
      status: "sent",
      sentAt: args.sentAt,
      updatedAt: Date.now(),
    });
    return args.id;
  },
});

/**
 * Reorder question templates (batch update sortOrder)
 */
export const reorderQuestionTemplates = mutation({
  args: {
    updates: v.array(
      v.object({
        id: v.id("review_question_templates"),
        sortOrder: v.number(),
      }),
    ),
  },
  handler: async (ctx, args) => {
    const now = Date.now();
    for (const update of args.updates) {
      await ctx.db.patch(update.id, {
        sortOrder: update.sortOrder,
        updatedAt: now,
      });
    }
    return args.updates.length;
  },
});
```

**Step 4: Run tests to verify they pass**

Run: `npx jest __tests__/convex/reviews-mutations.test.ts --no-cache`
Expected: PASS

**Step 5: Commit**

```bash
git add convex/reviews/mutations.ts __tests__/convex/reviews-mutations.test.ts
git commit -m "feat(convex): add missing reviews mutations — updateReview, question templates, sendRequest"
```

---

## Task 2: Reviews — Gap-Fill Missing Convex Queries

**Files:**

- Modify: `convex/reviews/queries.ts`
- Test: `__tests__/convex/reviews-queries.test.ts` (create)

**Step 1: Write failing tests**

Create `__tests__/convex/reviews-queries.test.ts`:

```typescript
import { convexTest } from "convex-test";
import { api } from "../../convex/_generated/api";
import schema from "../../convex/schema";

describe("Reviews queries - gap fill", () => {
  test("getReviewStats returns aggregate counts", async () => {
    const t = convexTest(schema);
    // Seed reviews
    await t.mutation(api.reviews.mutations.createReview, {
      userId: "user1",
      organizationId: "org1",
      customerName: "A",
      rating: 5,
      source: "google",
    });
    await t.mutation(api.reviews.mutations.createReview, {
      userId: "user1",
      organizationId: "org1",
      customerName: "B",
      rating: 3,
      source: "manual",
    });
    const stats = await t.query(api.reviews.queries.getReviewStats, {
      userId: "user1",
    });
    expect(stats.totalReviews).toBe(2);
    expect(stats.averageRating).toBe(4);
  });

  test("getQuestionTemplates returns sorted templates", async () => {
    const t = convexTest(schema);
    await t.mutation(api.reviews.mutations.createQuestionTemplate, {
      userId: "user1",
      organizationId: "org1",
      question: "Second?",
      questionType: "text",
      isRequired: false,
      sortOrder: 1,
    });
    await t.mutation(api.reviews.mutations.createQuestionTemplate, {
      userId: "user1",
      organizationId: "org1",
      question: "First?",
      questionType: "rating",
      isRequired: true,
      sortOrder: 0,
    });
    const templates = await t.query(api.reviews.queries.getQuestionTemplates, {
      userId: "user1",
    });
    expect(templates).toHaveLength(2);
    expect(templates[0].question).toBe("First?");
  });

  test("getReviewRequestStats returns counts by status", async () => {
    const t = convexTest(schema);
    await t.mutation(api.reviews.mutations.createReviewRequest, {
      userId: "user1",
      organizationId: "org1",
      customerName: "Jane",
      customerEmail: "j@t.com",
      customerPhone: "+97412345678",
      bookingId: "b1",
      channel: "email",
    });
    const stats = await t.query(api.reviews.queries.getReviewRequestStats, {
      userId: "user1",
    });
    expect(stats.total).toBeGreaterThanOrEqual(1);
    expect(stats.pending).toBeGreaterThanOrEqual(1);
  });

  test("getEligibleBookings returns bookings without review requests", async () => {
    const t = convexTest(schema);
    const result = await t.query(api.reviews.queries.getEligibleBookings, {
      userId: "user1",
    });
    expect(Array.isArray(result)).toBe(true);
  });
});
```

**Step 2: Run tests to verify they fail**

Run: `npx jest __tests__/convex/reviews-queries.test.ts --no-cache`
Expected: FAIL — missing functions

**Step 3: Implement missing queries in `convex/reviews/queries.ts`**

Add to existing file:

```typescript
/**
 * Get review aggregate stats for a user/org
 */
export const getReviewStats = query({
  args: { userId: v.string(), organizationId: v.optional(v.string()) },
  handler: async (ctx, args) => {
    const reviews = await ctx.db
      .query("reviews")
      .filter((q) => q.eq(q.field("userId"), args.userId))
      .collect();
    const totalReviews = reviews.length;
    const averageRating =
      totalReviews > 0
        ? reviews.reduce((sum, r) => sum + r.rating, 0) / totalReviews
        : 0;
    const ratingDistribution = [0, 0, 0, 0, 0];
    for (const r of reviews) {
      if (r.rating >= 1 && r.rating <= 5) ratingDistribution[r.rating - 1]++;
    }
    const featuredCount = reviews.filter((r) => r.featured).length;
    const sourceBreakdown: Record<string, number> = {};
    for (const r of reviews) {
      sourceBreakdown[r.source] = (sourceBreakdown[r.source] || 0) + 1;
    }
    return {
      totalReviews,
      averageRating,
      ratingDistribution,
      featuredCount,
      sourceBreakdown,
    };
  },
});

/**
 * Get question templates sorted by sortOrder
 */
export const getQuestionTemplates = query({
  args: { userId: v.string() },
  handler: async (ctx, args) => {
    const templates = await ctx.db
      .query("review_question_templates")
      .filter((q) => q.eq(q.field("userId"), args.userId))
      .collect();
    return templates.sort((a, b) => a.sortOrder - b.sortOrder);
  },
});

/**
 * Get review request stats by status
 */
export const getReviewRequestStats = query({
  args: { userId: v.string() },
  handler: async (ctx, args) => {
    const requests = await ctx.db
      .query("review_requests")
      .filter((q) => q.eq(q.field("userId"), args.userId))
      .collect();
    const total = requests.length;
    const pending = requests.filter((r) => r.status === "pending").length;
    const sent = requests.filter((r) => r.status === "sent").length;
    const completed = requests.filter((r) => r.status === "completed").length;
    const expired = requests.filter((r) => r.status === "expired").length;
    return { total, pending, sent, completed, expired };
  },
});

/**
 * Get bookings that don't have review requests yet (eligible for review)
 */
export const getEligibleBookings = query({
  args: { userId: v.string(), limit: v.optional(v.number()) },
  handler: async (ctx, args) => {
    const requests = await ctx.db
      .query("review_requests")
      .filter((q) => q.eq(q.field("userId"), args.userId))
      .collect();
    const bookedIds = new Set(requests.map((r) => r.bookingId).filter(Boolean));
    const bookings = await ctx.db
      .query("bookings")
      .filter((q) =>
        q.and(
          q.eq(q.field("userId"), args.userId),
          q.eq(q.field("status"), "completed"),
        ),
      )
      .collect();
    const eligible = bookings
      .filter((b) => !bookedIds.has(b._id))
      .slice(0, args.limit ?? 50);
    return eligible;
  },
});
```

**Step 4: Run tests**

Run: `npx jest __tests__/convex/reviews-queries.test.ts --no-cache`
Expected: PASS

**Step 5: Commit**

```bash
git add convex/reviews/queries.ts __tests__/convex/reviews-queries.test.ts
git commit -m "feat(convex): add reviews queries — stats, question templates, eligible bookings"
```

---

## Task 3: Content (Blog) — Implement Specialized Convex Functions

**Files:**

- Modify: `convex/content/mutations.ts`
- Modify: `convex/content/queries.ts`
- Test: `__tests__/convex/content-blog.test.ts` (create)

**Step 1: Write failing tests**

Create `__tests__/convex/content-blog.test.ts`:

```typescript
import { convexTest } from "convex-test";
import { api } from "../../convex/_generated/api";
import schema from "../../convex/schema";

describe("Content/Blog Convex functions", () => {
  test("createBlogPost creates with all fields", async () => {
    const t = convexTest(schema);
    const id = await t.mutation(api.content.mutations.createBlogPost, {
      title: "Test Post",
      titleAr: "مقال تجريبي",
      slug: "test-post",
      content: "Body text here",
      contentAr: "نص المقال هنا",
      excerpt: "Short excerpt",
      language: "en",
      authorId: "author1",
      authorName: "Test Author",
      status: "draft",
      tags: ["tech", "test"],
    });
    expect(id).toBeDefined();
  });

  test("getBlogPosts returns paginated results", async () => {
    const t = convexTest(schema);
    for (let i = 0; i < 5; i++) {
      await t.mutation(api.content.mutations.createBlogPost, {
        title: `Post ${i}`,
        slug: `post-${i}`,
        content: "Content",
        language: "en",
        authorId: "a1",
        authorName: "Author",
        status: "published",
        tags: [],
      });
    }
    const results = await t.query(api.content.queries.getBlogPosts, {
      status: "published",
      limit: 3,
    });
    expect(results.posts).toHaveLength(3);
    expect(results.total).toBe(5);
  });

  test("getBlogPostBySlug returns single post", async () => {
    const t = convexTest(schema);
    await t.mutation(api.content.mutations.createBlogPost, {
      title: "Slug Test",
      slug: "slug-test",
      content: "Content",
      language: "en",
      authorId: "a1",
      authorName: "Author",
      status: "published",
      tags: [],
    });
    const post = await t.query(api.content.queries.getBlogPostBySlug, {
      slug: "slug-test",
    });
    expect(post).not.toBeNull();
    expect(post?.title).toBe("Slug Test");
  });

  test("updateBlogPost modifies fields", async () => {
    const t = convexTest(schema);
    const id = await t.mutation(api.content.mutations.createBlogPost, {
      title: "Original",
      slug: "original",
      content: "Content",
      language: "en",
      authorId: "a1",
      authorName: "Author",
      status: "draft",
      tags: [],
    });
    await t.mutation(api.content.mutations.updateBlogPost, {
      id,
      title: "Updated",
      status: "published",
    });
  });

  test("deleteBlogPost removes post", async () => {
    const t = convexTest(schema);
    const id = await t.mutation(api.content.mutations.createBlogPost, {
      title: "To Delete",
      slug: "to-delete",
      content: "Content",
      language: "en",
      authorId: "a1",
      authorName: "Author",
      status: "draft",
      tags: [],
    });
    await t.mutation(api.content.mutations.deleteBlogPost, { id });
  });

  test("getBlogStats returns counts by status", async () => {
    const t = convexTest(schema);
    await t.mutation(api.content.mutations.createBlogPost, {
      title: "Draft",
      slug: "draft-1",
      content: "C",
      language: "en",
      authorId: "a1",
      authorName: "A",
      status: "draft",
      tags: [],
    });
    await t.mutation(api.content.mutations.createBlogPost, {
      title: "Published",
      slug: "pub-1",
      content: "C",
      language: "en",
      authorId: "a1",
      authorName: "A",
      status: "published",
      tags: [],
    });
    const stats = await t.query(api.content.queries.getBlogStats, {});
    expect(stats.draft).toBe(1);
    expect(stats.published).toBe(1);
    expect(stats.total).toBe(2);
  });

  test("getAllBlogTags returns unique tags", async () => {
    const t = convexTest(schema);
    await t.mutation(api.content.mutations.createBlogPost, {
      title: "P1",
      slug: "p1",
      content: "C",
      language: "en",
      authorId: "a1",
      authorName: "A",
      status: "published",
      tags: ["tech", "ai"],
    });
    await t.mutation(api.content.mutations.createBlogPost, {
      title: "P2",
      slug: "p2",
      content: "C",
      language: "en",
      authorId: "a1",
      authorName: "A",
      status: "published",
      tags: ["tech", "saas"],
    });
    const tags = await t.query(api.content.queries.getAllBlogTags, {});
    expect(tags).toContain("tech");
    expect(tags).toContain("ai");
    expect(tags).toContain("saas");
  });

  test("bulkUpdateBlogStatus changes multiple posts", async () => {
    const t = convexTest(schema);
    const id1 = await t.mutation(api.content.mutations.createBlogPost, {
      title: "B1",
      slug: "b1",
      content: "C",
      language: "en",
      authorId: "a1",
      authorName: "A",
      status: "draft",
      tags: [],
    });
    const id2 = await t.mutation(api.content.mutations.createBlogPost, {
      title: "B2",
      slug: "b2",
      content: "C",
      language: "en",
      authorId: "a1",
      authorName: "A",
      status: "draft",
      tags: [],
    });
    await t.mutation(api.content.mutations.bulkUpdateBlogStatus, {
      ids: [id1, id2],
      status: "published",
    });
  });
});
```

**Step 2: Run tests to verify they fail**

Run: `npx jest __tests__/convex/content-blog.test.ts --no-cache`
Expected: FAIL

**Step 3: Replace generic content mutations with specialized blog functions**

Replace `convex/content/mutations.ts` with:

```typescript
import { mutation } from "../_generated/server";
import { v } from "convex/values";

export const createBlogPost = mutation({
  args: {
    title: v.string(),
    titleAr: v.optional(v.string()),
    slug: v.string(),
    content: v.string(),
    contentAr: v.optional(v.string()),
    excerpt: v.optional(v.string()),
    excerptAr: v.optional(v.string()),
    language: v.string(),
    authorId: v.string(),
    authorName: v.string(),
    status: v.string(),
    tags: v.array(v.string()),
    coverImageStorageId: v.optional(v.id("_storage")),
    featured: v.optional(v.boolean()),
    metaTitle: v.optional(v.string()),
    metaDescription: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    const now = Date.now();
    return await ctx.db.insert("blog_posts", {
      title: args.title,
      titleAr: args.titleAr,
      slug: args.slug,
      content: args.content,
      contentAr: args.contentAr,
      excerpt: args.excerpt,
      excerptAr: args.excerptAr,
      language: args.language,
      authorId: args.authorId,
      authorName: args.authorName,
      status: args.status as "draft" | "published" | "archived",
      tags: args.tags,
      coverImageStorageId: args.coverImageStorageId,
      featured: args.featured ?? false,
      viewCount: 0,
      metaTitle: args.metaTitle,
      metaDescription: args.metaDescription,
      publishedAt: args.status === "published" ? now : undefined,
      createdAt: now,
      updatedAt: now,
    });
  },
});

export const updateBlogPost = mutation({
  args: {
    id: v.id("blog_posts"),
    title: v.optional(v.string()),
    titleAr: v.optional(v.string()),
    slug: v.optional(v.string()),
    content: v.optional(v.string()),
    contentAr: v.optional(v.string()),
    excerpt: v.optional(v.string()),
    excerptAr: v.optional(v.string()),
    status: v.optional(v.string()),
    tags: v.optional(v.array(v.string())),
    coverImageStorageId: v.optional(v.id("_storage")),
    featured: v.optional(v.boolean()),
    metaTitle: v.optional(v.string()),
    metaDescription: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    const { id, ...updates } = args;
    const existing = await ctx.db.get(id);
    if (!existing) throw new Error(`Blog post ${id} not found`);
    const patch: Record<string, unknown> = { updatedAt: Date.now() };
    for (const [key, value] of Object.entries(updates)) {
      if (value !== undefined) patch[key] = value;
    }
    if (updates.status === "published" && existing.status !== "published") {
      patch.publishedAt = Date.now();
    }
    await ctx.db.patch(id, patch);
    return id;
  },
});

export const deleteBlogPost = mutation({
  args: { id: v.id("blog_posts") },
  handler: async (ctx, args) => {
    const existing = await ctx.db.get(args.id);
    if (!existing) throw new Error(`Blog post ${args.id} not found`);
    await ctx.db.delete(args.id);
    return args.id;
  },
});

export const bulkUpdateBlogStatus = mutation({
  args: {
    ids: v.array(v.id("blog_posts")),
    status: v.string(),
  },
  handler: async (ctx, args) => {
    const now = Date.now();
    for (const id of args.ids) {
      const patch: Record<string, unknown> = {
        status: args.status,
        updatedAt: now,
      };
      if (args.status === "published") patch.publishedAt = now;
      await ctx.db.patch(id, patch);
    }
    return args.ids.length;
  },
});

// Keep original generic createContent for backward compat
export const createContent = mutation({
  args: {
    type: v.string(),
    title: v.string(),
    body: v.string(),
    authorId: v.string(),
    status: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    const now = Date.now();
    return await ctx.db.insert("blog_posts", {
      title: args.title,
      content: args.body,
      slug: args.title.toLowerCase().replace(/\s+/g, "-"),
      language: "en",
      authorId: args.authorId,
      authorName: "System",
      status: (args.status as "draft" | "published" | "archived") ?? "draft",
      tags: [],
      featured: false,
      viewCount: 0,
      createdAt: now,
      updatedAt: now,
    });
  },
});

export const updateContent = mutation({
  args: {
    id: v.id("blog_posts"),
    title: v.optional(v.string()),
    body: v.optional(v.string()),
    status: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    const { id, ...updates } = args;
    const patch: Record<string, unknown> = { updatedAt: Date.now() };
    if (updates.title) patch.title = updates.title;
    if (updates.body) patch.content = updates.body;
    if (updates.status) patch.status = updates.status;
    await ctx.db.patch(id, patch);
    return id;
  },
});
```

Add to `convex/content/queries.ts`:

```typescript
import { query } from "../_generated/server";
import { v } from "convex/values";

export const getBlogPosts = query({
  args: {
    status: v.optional(v.string()),
    language: v.optional(v.string()),
    tag: v.optional(v.string()),
    featured: v.optional(v.boolean()),
    authorId: v.optional(v.string()),
    limit: v.optional(v.number()),
    offset: v.optional(v.number()),
  },
  handler: async (ctx, args) => {
    let posts = await ctx.db.query("blog_posts").collect();
    if (args.status) posts = posts.filter((p) => p.status === args.status);
    if (args.language)
      posts = posts.filter((p) => p.language === args.language);
    if (args.tag) posts = posts.filter((p) => p.tags?.includes(args.tag!));
    if (args.featured !== undefined)
      posts = posts.filter((p) => p.featured === args.featured);
    if (args.authorId)
      posts = posts.filter((p) => p.authorId === args.authorId);
    posts.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
    const total = posts.length;
    const offset = args.offset ?? 0;
    const limit = args.limit ?? 20;
    return { posts: posts.slice(offset, offset + limit), total };
  },
});

export const getBlogPostBySlug = query({
  args: { slug: v.string() },
  handler: async (ctx, args) => {
    const posts = await ctx.db
      .query("blog_posts")
      .filter((q) => q.eq(q.field("slug"), args.slug))
      .collect();
    return posts[0] ?? null;
  },
});

export const getBlogStats = query({
  args: {},
  handler: async (ctx) => {
    const posts = await ctx.db.query("blog_posts").collect();
    const total = posts.length;
    const draft = posts.filter((p) => p.status === "draft").length;
    const published = posts.filter((p) => p.status === "published").length;
    const archived = posts.filter((p) => p.status === "archived").length;
    const featured = posts.filter((p) => p.featured).length;
    const tagCounts: Record<string, number> = {};
    for (const post of posts) {
      for (const tag of post.tags ?? []) {
        tagCounts[tag] = (tagCounts[tag] || 0) + 1;
      }
    }
    return { total, draft, published, archived, featured, tagCounts };
  },
});

export const getAllBlogTags = query({
  args: {},
  handler: async (ctx) => {
    const posts = await ctx.db.query("blog_posts").collect();
    const tagSet = new Set<string>();
    for (const post of posts) {
      for (const tag of post.tags ?? []) tagSet.add(tag);
    }
    return Array.from(tagSet).sort();
  },
});

// Keep original generic queries
export const getContent = query({
  args: { type: v.optional(v.string()), limit: v.optional(v.number()) },
  handler: async (ctx, args) => {
    const posts = await ctx.db.query("blog_posts").collect();
    return posts.slice(0, args.limit ?? 20);
  },
});
```

**Step 4: Run tests**

Run: `npx jest __tests__/convex/content-blog.test.ts --no-cache`
Expected: PASS

**Step 5: Commit**

```bash
git add convex/content/mutations.ts convex/content/queries.ts __tests__/convex/content-blog.test.ts
git commit -m "feat(convex): add specialized blog post CRUD, stats, tags queries"
```

---

## Task 4: Analytics — Gap-Fill Missing Convex Functions

**Files:**

- Modify: `convex/analytics/mutations.ts`
- Modify: `convex/analytics/queries.ts`
- Test: `__tests__/convex/analytics-functions.test.ts` (create)

**Step 1: Write failing tests**

Create `__tests__/convex/analytics-functions.test.ts`:

```typescript
import { convexTest } from "convex-test";
import { api } from "../../convex/_generated/api";
import schema from "../../convex/schema";

describe("Analytics Convex functions - gap fill", () => {
  test("getDashboardMetrics returns booking/customer/revenue counts", async () => {
    const t = convexTest(schema);
    const result = await t.query(api.analytics.queries.getDashboardMetrics, {
      userId: "user1",
    });
    expect(result).toHaveProperty("totalBookings");
    expect(result).toHaveProperty("totalCustomers");
    expect(result).toHaveProperty("totalRevenue");
  });

  test("getDailyStats returns date-grouped entries", async () => {
    const t = convexTest(schema);
    await t.mutation(api.analytics.mutations.trackEvent, {
      userId: "user1",
      eventType: "page_view",
      eventData: {},
    });
    const result = await t.query(api.analytics.queries.getDailyStats, {
      userId: "user1",
      startDate: Date.now() - 86400000,
      endDate: Date.now(),
    });
    expect(Array.isArray(result)).toBe(true);
  });

  test("recordWebVital stores performance data", async () => {
    const t = convexTest(schema);
    const id = await t.mutation(api.analytics.mutations.recordWebVital, {
      name: "LCP",
      value: 2500,
      rating: "good",
      url: "/dashboard",
      userAgent: "test-agent",
    });
    expect(id).toBeDefined();
  });

  test("upsertWorkflowMetrics creates or updates analytics row", async () => {
    const t = convexTest(schema);
    const id = await t.mutation(api.analytics.mutations.upsertWorkflowMetrics, {
      workflowId: "wf1",
      userId: "user1",
      executionCount: 10,
      successCount: 9,
      failureCount: 1,
      avgDurationMs: 1500,
    });
    expect(id).toBeDefined();
  });
});
```

**Step 2: Run tests to verify they fail**

Run: `npx jest __tests__/convex/analytics-functions.test.ts --no-cache`
Expected: FAIL

**Step 3: Implement missing analytics functions**

Add to `convex/analytics/mutations.ts`:

```typescript
/**
 * Record a web vital metric
 */
export const recordWebVital = mutation({
  args: {
    name: v.string(),
    value: v.number(),
    rating: v.string(),
    url: v.string(),
    userAgent: v.optional(v.string()),
    navigationType: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    return await ctx.db.insert("web_vitals", {
      name: args.name,
      value: args.value,
      rating: args.rating as "good" | "needs-improvement" | "poor",
      url: args.url,
      userAgent: args.userAgent,
      navigationType: args.navigationType,
      recordedAt: Date.now(),
    });
  },
});

/**
 * Upsert workflow analytics metrics (from n8n webhooks)
 */
export const upsertWorkflowMetrics = mutation({
  args: {
    workflowId: v.string(),
    userId: v.string(),
    executionCount: v.number(),
    successCount: v.number(),
    failureCount: v.number(),
    avgDurationMs: v.number(),
  },
  handler: async (ctx, args) => {
    const existing = await ctx.db
      .query("workflow_analytics")
      .filter((q) => q.eq(q.field("workflowId"), args.workflowId))
      .first();
    if (existing) {
      await ctx.db.patch(existing._id, {
        executionCount: args.executionCount,
        successCount: args.successCount,
        failureCount: args.failureCount,
        avgDurationMs: args.avgDurationMs,
        updatedAt: Date.now(),
      });
      return existing._id;
    }
    return await ctx.db.insert("workflow_analytics", {
      workflowId: args.workflowId,
      userId: args.userId,
      executionCount: args.executionCount,
      successCount: args.successCount,
      failureCount: args.failureCount,
      avgDurationMs: args.avgDurationMs,
      createdAt: Date.now(),
      updatedAt: Date.now(),
    });
  },
});
```

Add to `convex/analytics/queries.ts`:

```typescript
/**
 * Get dashboard metrics (aggregate from bookings + customers)
 */
export const getDashboardMetrics = query({
  args: { userId: v.string() },
  handler: async (ctx, args) => {
    const bookings = await ctx.db
      .query("bookings")
      .filter((q) => q.eq(q.field("userId"), args.userId))
      .collect();
    const customers = await ctx.db
      .query("customers")
      .filter((q) => q.eq(q.field("userId"), args.userId))
      .collect();
    const totalRevenue = bookings.reduce(
      (sum, b) => sum + (b.totalAmount ?? 0),
      0,
    );
    const completedBookings = bookings.filter(
      (b) => b.status === "completed",
    ).length;
    return {
      totalBookings: bookings.length,
      completedBookings,
      pendingBookings: bookings.filter((b) => b.status === "pending").length,
      cancelledBookings: bookings.filter((b) => b.status === "cancelled")
        .length,
      totalCustomers: customers.length,
      totalRevenue,
    };
  },
});

/**
 * Get daily stats for a date range
 */
export const getDailyStats = query({
  args: {
    userId: v.string(),
    startDate: v.number(),
    endDate: v.number(),
  },
  handler: async (ctx, args) => {
    const events = await ctx.db
      .query("analytics_events")
      .filter((q) =>
        q.and(
          q.eq(q.field("userId"), args.userId),
          q.gte(q.field("createdAt"), args.startDate),
          q.lte(q.field("createdAt"), args.endDate),
        ),
      )
      .collect();
    const dailyMap: Record<
      string,
      { date: string; pageViews: number; events: number }
    > = {};
    for (const event of events) {
      const date = new Date(event.createdAt).toISOString().slice(0, 10);
      if (!dailyMap[date]) dailyMap[date] = { date, pageViews: 0, events: 0 };
      dailyMap[date].events++;
      if (event.eventType === "page_view") dailyMap[date].pageViews++;
    }
    return Object.values(dailyMap).sort((a, b) => a.date.localeCompare(b.date));
  },
});
```

**Note:** The `web_vitals` table may need to be added to the Convex schema. Check `convex/schema/analytics.ts` and add it if missing.

**Step 4: Run tests**

Run: `npx jest __tests__/convex/analytics-functions.test.ts --no-cache`
Expected: PASS

**Step 5: Commit**

```bash
git add convex/analytics/mutations.ts convex/analytics/queries.ts __tests__/convex/analytics-functions.test.ts
git commit -m "feat(convex): add analytics gap-fill — web vitals, dashboard metrics, daily stats"
```

---

## Task 5: Wire Remaining Reviews API Routes

**Files:**

- Modify: `app/api/dashboard/reviews/requests/route.ts`
- Modify: `app/api/dashboard/reviews/requests/send/route.ts`
- Modify: `app/api/dashboard/reviews/requests/eligible-bookings/route.ts`
- Modify: `app/api/dashboard/reviews/requests/bulk/route.ts`
- Modify: `app/api/dashboard/reviews/auto-trigger/route.ts`
- Modify: `app/api/dashboard/reviews/testimonials/route.ts`
- Modify: `app/api/dashboard/reviews/testimonials/reorder/route.ts`
- Modify: `app/api/dashboard/reviews/testimonials/embed/route.ts`
- Modify: `app/api/dashboard/reviews/questions/route.ts`
- Modify: `app/api/dashboard/reviews/questions/generate/route.ts`
- Modify: `app/api/dashboard/reviews/questions/reorder/route.ts`
- Modify: `app/api/dashboard/reviews/questions/[templateId]/route.ts`

**Pattern for each route:** Add the `getDataSource("reviews") === "convex"` block at the top of each handler, before the existing Prisma logic. Dynamic-import Convex functions. Return early if on Convex.

**Step 1: Add routing to `reviews/requests/route.ts`**

At the top of the GET handler, after auth check:

```typescript
import { getDataSource } from "@/lib/data-source";

// Inside GET handler, after auth:
if (getDataSource("reviews") === "convex") {
  const { convexQuery } = await import("@/lib/convex-client");
  const { api } = await import("@/convex/_generated/api");
  const requests = await convexQuery(api.reviews.queries.getReviewRequests, {
    userId: user.id,
  });
  return NextResponse.json({ requests });
}
```

Apply this same pattern to each of the 12 routes listed above, mapping each Prisma operation to its corresponding Convex function.

**Step 2: Test each route manually**

For each route, test with `DS_REVIEWS=prisma` (default) and `DS_REVIEWS=convex`:

```bash
DS_REVIEWS=convex curl -H "Authorization: Bearer $TOKEN" http://localhost:9000/api/dashboard/reviews/requests
```

**Step 3: Commit**

```bash
git add app/api/dashboard/reviews/
git commit -m "feat(reviews): wire all 12 remaining review routes with data-source routing"
```

---

## Task 6: Wire Content (Blog) API Routes

**Files:**

- Modify: `app/api/staff/dashboard/blog/route.ts`
- Modify: `app/api/staff/dashboard/blog/[id]/route.ts`
- Modify: `app/api/staff/dashboard/blog/bulk-update/route.ts`
- Modify: `app/api/staff/dashboard/blog/stats/route.ts`

**Step 1: Add routing to each blog route**

Same pattern as Task 5. For `staff/dashboard/blog/route.ts` GET:

```typescript
if (getDataSource("content") === "convex") {
  const { convexQuery } = await import("@/lib/convex-client");
  const { api } = await import("@/convex/_generated/api");
  const { posts, total } = await convexQuery(api.content.queries.getBlogPosts, {
    status: searchParams.get("status") ?? undefined,
    language: searchParams.get("language") ?? undefined,
    limit: parseInt(searchParams.get("limit") ?? "20"),
    offset: parseInt(searchParams.get("offset") ?? "0"),
  });
  return NextResponse.json({ posts, total });
}
```

**Step 2: Test with env var override**

```bash
DS_CONTENT=convex curl http://localhost:9000/api/staff/dashboard/blog
```

**Step 3: Commit**

```bash
git add app/api/staff/dashboard/blog/
git commit -m "feat(content): wire all 4 blog staff routes with data-source routing"
```

---

## Task 7: Wire Remaining Analytics API Routes

**Files:**

- Modify: `app/api/analytics/web-vitals/route.ts`
- Modify: `app/api/dashboard/analytics/daily/route.ts`
- Modify: `app/api/staff/dashboard/analytics/route.ts`

**Step 1: Add routing to each analytics route**

Same pattern. For `web-vitals/route.ts` POST:

```typescript
if (getDataSource("analytics") === "convex") {
  const { convexMutation } = await import("@/lib/convex-client");
  const { api } = await import("@/convex/_generated/api");
  await convexMutation(api.analytics.mutations.recordWebVital, {
    name: body.name,
    value: body.value,
    rating: body.rating,
    url: body.url,
    userAgent: request.headers.get("user-agent") ?? undefined,
  });
  return NextResponse.json({ success: true });
}
```

**Step 2: Test and commit**

```bash
git add app/api/analytics/ app/api/dashboard/analytics/ app/api/staff/dashboard/analytics/
git commit -m "feat(analytics): wire remaining 3 analytics routes with data-source routing"
```

---

## Task 8: Verify Convex Schema Has All Required Tables

**Files:**

- Modify: `convex/schema/reviews.ts` (if missing `review_question_templates`)
- Modify: `convex/schema/analytics.ts` (if missing `web_vitals`)
- Modify: `convex/schema/content.ts` (if missing blog-specific fields)

**Step 1: Check schema files for missing tables**

Read each schema file. Verify:

- `review_question_templates` table exists with: userId, organizationId, question, questionAr, questionType, isRequired, sortOrder, options, isActive, createdAt, updatedAt
- `web_vitals` table exists with: name, value, rating, url, userAgent, navigationType, recordedAt
- `blog_posts` table has: title, titleAr, slug, content, contentAr, excerpt, excerptAr, language, authorId, authorName, status, tags, coverImageStorageId, featured, viewCount, metaTitle, metaDescription, publishedAt, createdAt, updatedAt

**Step 2: Add any missing tables/fields to schema**

**Step 3: Push schema to Convex**

```bash
CONVEX_DEPLOYMENT=dev:tacit-chinchilla-978 npx convex dev --once
```

**Step 4: Commit**

```bash
git add convex/schema/
git commit -m "fix(convex): ensure schema has all Wave 1 tables — question templates, web vitals, blog fields"
```

---

## Task 9: Write Data Migration Script for Wave 1

**Files:**

- Create: `scripts/migrate-wave1-to-convex.ts`
- Test: Run against dev Convex deployment

**Step 1: Create migration script**

```typescript
#!/usr/bin/env tsx
/**
 * Wave 1 Data Migration: Prisma → Convex
 * Domains: content (blog_posts), reviews, analytics
 *
 * Usage: npx tsx scripts/migrate-wave1-to-convex.ts
 */

import { PrismaClient } from "@/lib/generated/prisma";
import { ConvexHttpClient } from "convex/browser";
import { api } from "../convex/_generated/api";

const prisma = new PrismaClient();
const convex = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL!);

async function migrateReviews() {
  console.log("--- Migrating reviews ---");
  const reviews = await prisma.reviews.findMany();
  console.log(`Found ${reviews.length} reviews`);
  let migrated = 0;
  for (const review of reviews) {
    try {
      await convex.mutation(api.reviews.mutations.createReview, {
        userId: review.userId,
        organizationId: review.organizationId ?? undefined,
        customerName: review.customerName,
        rating: review.rating,
        comment: review.comment ?? undefined,
        source: review.source,
      });
      migrated++;
    } catch (err) {
      console.error(`Failed to migrate review ${review.id}:`, err);
    }
  }
  console.log(`Migrated ${migrated}/${reviews.length} reviews`);
}

async function migrateBlogPosts() {
  console.log("--- Migrating blog posts ---");
  const posts = await prisma.blog_posts.findMany();
  console.log(`Found ${posts.length} blog posts`);
  let migrated = 0;
  for (const post of posts) {
    try {
      await convex.mutation(api.content.mutations.createBlogPost, {
        title: post.title,
        titleAr: post.titleAr ?? undefined,
        slug: post.slug,
        content: post.content,
        contentAr: post.contentAr ?? undefined,
        excerpt: post.excerpt ?? undefined,
        language: post.language ?? "en",
        authorId: post.authorId ?? "system",
        authorName: post.authorName ?? "System",
        status: post.status ?? "draft",
        tags: post.tags ?? [],
        featured: post.featured ?? false,
        metaTitle: post.metaTitle ?? undefined,
        metaDescription: post.metaDescription ?? undefined,
      });
      migrated++;
    } catch (err) {
      console.error(`Failed to migrate blog post ${post.id}:`, err);
    }
  }
  console.log(`Migrated ${migrated}/${posts.length} blog posts`);
}

async function migrateAnalyticsEvents() {
  console.log("--- Migrating analytics events ---");
  const events = await prisma.analytics_events.findMany({ take: 10000 });
  console.log(`Found ${events.length} analytics events (capped at 10k)`);
  let migrated = 0;
  for (const event of events) {
    try {
      await convex.mutation(api.analytics.mutations.trackEvent, {
        userId: event.userId ?? "anonymous",
        eventType: event.eventType,
        eventData: event.eventData ?? {},
      });
      migrated++;
    } catch (err) {
      console.error(`Failed to migrate event ${event.id}:`, err);
    }
  }
  console.log(`Migrated ${migrated}/${events.length} analytics events`);
}

async function main() {
  console.log("=== Wave 1 Data Migration: Prisma → Convex ===\n");
  await migrateReviews();
  await migrateBlogPosts();
  await migrateAnalyticsEvents();
  console.log("\n=== Migration complete ===");
  await prisma.$disconnect();
}

main().catch(console.error);
```

**Step 2: Test against dev Convex**

```bash
CONVEX_DEPLOYMENT=dev:tacit-chinchilla-978 npx tsx scripts/migrate-wave1-to-convex.ts
```

**Step 3: Verify record counts match**

**Step 4: Commit**

```bash
git add scripts/migrate-wave1-to-convex.ts
git commit -m "feat(migration): add Wave 1 data migration script — reviews, blog, analytics"
```

---

## Task 10: Flip Wave 1 Domains to Convex & Validate

**Step 1: Set environment variables**

In `.env.local`:

```
DS_CONTENT=convex
DS_REVIEWS=convex
DS_ANALYTICS=convex
```

**Step 2: Start dev server and test all affected routes**

```bash
npm run dev
```

Test each domain's routes manually and with existing test suites:

```bash
npx jest __tests__/api/dashboard/reviews --no-cache
npx jest __tests__/api/staff/blog --no-cache
npx jest __tests__/api/analytics --no-cache
```

**Step 3: Verify rollback works**

Remove env vars → verify routes fall back to Prisma cleanly.

**Step 4: Commit the env var changes**

```bash
git commit -m "feat: flip Wave 1 domains to Convex — content, reviews, analytics"
```

---

## Waves 2-7 Roadmap (Summary)

Each wave follows the same cycle as Wave 1: gap-fill → wire → migrate data → flip → validate.

### Wave 2: careers, expenses, nurture (~12-17 days)

- **Expenses:** Nearly complete — wire POST route, add category endpoints
- **Careers:** Complex — needs interview token service, file upload via Convex storage, staff notes
- **Nurture:** Needs sequence CRUD + enrollment workflow in Convex

### Wave 3: leads, social, customers (~10-14 days)

- Leads core CRUD mostly done, needs routing on POST/PUT/DELETE
- Social accounts/calendar/inbox need full Convex functions
- Customers need CRUD + search queries

### Wave 4: support, workflows, real_estate (~8-12 days)

- Support tickets already partially wired
- Workflows need full CRUD
- Real estate is complex (10 models, 15+ analytics queries)

### Wave 5: bookings, notifications, quotations, whatsapp, voice_agents (~15-20 days)

- Core business domains — extensive testing required
- Bookings: 8 routes, Stripe integration
- WhatsApp: webhook routing, Twilio integration
- Quotations: portal routes with token auth

### Wave 6: billing, organizations, auth (~10-15 days)

- Billing: Stripe webhooks, subscription management
- Organizations: member management, invitations
- Auth: Clerk integration, session management

### Wave 7: Cleanup (~5-7 days)

- Remove `lib/dual-database.ts`, `lib/sync-queue.ts`, `lib/supabase.ts`
- Remove `lib/data-source.ts` routing layer
- Remove Prisma schema, `lib/db.ts`, `@prisma/client`
- Remove `@supabase/supabase-js`
- Update all imports and tests
