AgentSkillsCN

wraps-api-developer

在 AWS Lambda 上为 Wraps 平台构建 Elysia.js API 路由。当您需要在 apps/api 中创建或编辑 API 端点时使用。

SKILL.md
--- frontmatter
name: wraps-api-developer
description: Build API routes for Wraps platform on AWS Lambda with Elysia.js. Use when creating or editing API endpoints in apps/api.

Wraps API Developer Skill

You are an expert at building API routes for the Wraps platform, which runs on AWS Lambda via Elysia.js.

Critical Lambda/Serverless Rules

ALWAYS AWAIT ASYNC OPERATIONS

Lambda terminates when the handler returns. Any fire-and-forget promises will be killed.

typescript
// BAD - Lambda will terminate before this completes
emitWorkflowEvent({ ... }).catch(console.error);
checkSegmentEntry({ ... }).catch(console.error);

// GOOD - Await ensures completion before response
await emitWorkflowEvent({ ... }).catch(console.error);
await checkSegmentEntry({ ... }).catch(console.error);

// GOOD - Parallel awaits for multiple operations
await Promise.all([
  emitWorkflowEvent({ ... }),
  checkSegmentEntry({ ... }),
]).catch(console.error);

Common Fire-and-Forget Patterns to Avoid

typescript
// BAD - These will be killed when Lambda terminates
someAsyncFunction().catch(err => console.error(err));
someAsyncFunction().then(handleResult);
for (const item of items) {
  processItem(item).catch(console.error);  // Not awaited!
}

// GOOD - Properly awaited
await someAsyncFunction().catch(err => console.error(err));
const result = await someAsyncFunction();
await Promise.all(items.map(item =>
  processItem(item).catch(console.error)
));

Wraps API Architecture

Tech Stack

  • Framework: Elysia.js
  • Database: Drizzle ORM with PostgreSQL
  • Runtime: AWS Lambda (via serverless)
  • Auth: API keys and session-based auth

Project Structure

code
apps/api/src/
├── routes/           # API route handlers
│   ├── contacts.ts   # Contact CRUD + workflow triggers
│   ├── topics.ts     # Topic management
│   ├── workflows.ts  # Workflow management
│   └── events.ts     # Custom event emission
├── services/         # Business logic
│   ├── workflow-events.ts  # Workflow trigger helpers
│   └── ...
├── middleware/       # Auth, logging, etc.
└── index.ts          # App entry point

Standard Route Pattern

typescript
import { t } from "elysia";
import { createAuthenticatedRoutes } from "../middleware/auth";
import { db, someTable, eq, and } from "@wraps/db";

export const myRoutes = createAuthenticatedRoutes("/v1/my-resource")
  .get(
    "/",
    async (ctx) => {
      const authContext = (ctx as unknown as { auth: AuthContext }).auth;

      const items = await db
        .select()
        .from(someTable)
        .where(eq(someTable.organizationId, authContext.organizationId));

      return { items };
    },
    {
      query: t.Object({
        page: t.Optional(t.String()),
      }),
      detail: {
        tags: ["my-resource"],
        summary: "List items",
        description: "Lists all items for the organization",
      },
    }
  )
  .post(
    "/",
    async (ctx) => {
      const { body } = ctx;
      const authContext = (ctx as unknown as { auth: AuthContext }).auth;

      // Validate
      if (!body.name) {
        ctx.set.status = 400;
        return { error: "Name is required" };
      }

      // Create
      const [created] = await db
        .insert(someTable)
        .values({
          organizationId: authContext.organizationId,
          name: body.name,
        })
        .returning();

      // IMPORTANT: Await any workflow/event emissions
      await emitSomeEvent({
        resourceId: created.id,
        organizationId: authContext.organizationId,
      }).catch((err) => {
        console.error("[my-resource] Failed to emit event:", err);
      });

      ctx.set.status = 201;
      return { id: created.id, name: created.name };
    },
    {
      body: t.Object({
        name: t.String(),
      }),
      detail: {
        tags: ["my-resource"],
        summary: "Create item",
        description: "Creates a new item",
      },
    }
  );

Workflow Event Emissions

Available Event Emitters

typescript
import {
  emitContactCreated,
  emitContactUpdated,
  emitTopicSubscribed,
  emitTopicUnsubscribed,
  emitWorkflowEvent,
  checkSegmentEntry,
} from "../services/workflow-events";

When to Emit Events

ActionEvent to Emit
Contact createdemitContactCreated + checkSegmentEntry + emitTopicSubscribed (if topics)
Contact updatedemitContactUpdated + checkSegmentEntry
Topic subscribedemitTopicSubscribed
Topic unsubscribedemitTopicUnsubscribed
Custom eventemitWorkflowEvent

Event Emission Pattern

typescript
// Single event - await it
await emitContactCreated({
  contactId: newContact.id,
  organizationId: authContext.organizationId,
  contactData: { ... },
}).catch((err) => {
  console.error("[contacts] Failed to emit contact_created:", err);
});

// Multiple events - await all in parallel
await Promise.all([
  emitContactCreated({ ... }),
  checkSegmentEntry({ ... }),
]).catch((err) => {
  console.error("[contacts] Failed to emit events:", err);
});

// Multiple items - map and await all
await Promise.all(
  topicIds.map((topicId) =>
    emitTopicSubscribed({
      contactId: params.id,
      organizationId: authContext.organizationId,
      topicId,
      topicName: topicMap.get(topicId),
    }).catch((err) => {
      console.error("[contacts] Failed to emit topic_subscribed:", err);
    })
  )
);

Database Patterns

Drizzle Query Patterns

typescript
import { db, contact, eq, and, or, inArray, desc, sql } from "@wraps/db";

// Select with conditions
const contacts = await db
  .select()
  .from(contact)
  .where(
    and(
      eq(contact.organizationId, orgId),
      eq(contact.emailStatus, "active")
    )
  )
  .orderBy(desc(contact.createdAt))
  .limit(50);

// Insert with returning
const [newContact] = await db
  .insert(contact)
  .values({ ... })
  .returning();

// Update with returning
const [updated] = await db
  .update(contact)
  .set({ name: "New Name", updatedAt: new Date() })
  .where(eq(contact.id, contactId))
  .returning();

// Delete
await db
  .delete(contact)
  .where(eq(contact.id, contactId));

// JSONB queries
const workflows = await db
  .select()
  .from(workflow)
  .where(
    sql`${workflow.triggerConfig}->>'topicId' = ${topicId}`
  );

Error Handling

Standard Error Responses

typescript
// 400 Bad Request - Invalid input
ctx.set.status = 400;
return { error: "Email or phone is required" };

// 404 Not Found
ctx.set.status = 404;
return { error: "Contact not found" };

// 409 Conflict - Duplicate
ctx.set.status = 409;
return { error: "Contact with this email already exists" };

// 500 Internal Server Error (avoid exposing details)
ctx.set.status = 500;
return { error: "Failed to process request" };

Logging Errors

typescript
// Log with context for debugging
console.error("[contacts] Failed to create contact:", {
  error: err,
  email: body.email,
  organizationId: authContext.organizationId,
});

REST Semantics

HTTP Methods

MethodPurposeIdempotentExample
GETRead resource(s)YesList contacts, get contact
POSTCreate resourceNoCreate contact
PATCHPartial updateYesUpdate contact fields (ADD topics)
PUTFull replaceYesReplace all topics
DELETERemove resourceYesDelete contact

PATCH vs PUT for Sub-resources

typescript
// PATCH /contacts/:id - Adds topics (doesn't remove existing)
// Use for: Adding topics, updating fields
body: { topicSlugs: ["new-topic"] }  // Adds to existing

// PUT /contacts/:id/topics - Replaces all topics
// Use for: Setting exact topic list
body: { topicSlugs: ["only-these-topics"] }  // Replaces all

Testing Checklist

Before deploying API changes, verify:

  1. All async operations are awaited (no fire-and-forget)
  2. Workflow events emit correctly
  3. Error responses use correct status codes
  4. Auth context is properly accessed
  5. Database queries use organization scoping
  6. Input validation is present
  7. TypeScript types are correct (pnpm --filter @wraps/api typecheck)

Common Mistakes

1. Fire-and-Forget Promises

typescript
// BAD
emitEvent().catch(console.error);

// GOOD
await emitEvent().catch(console.error);

2. Missing Organization Scoping

typescript
// BAD - No org check, could access other orgs' data
const contact = await db.select().from(contact).where(eq(contact.id, id));

// GOOD - Always scope by organization
const contact = await db.select().from(contact).where(
  and(
    eq(contact.id, id),
    eq(contact.organizationId, authContext.organizationId)
  )
);

3. Not Awaiting Multiple Operations

typescript
// BAD - Loop doesn't await
for (const id of ids) {
  processItem(id).catch(console.error);
}

// GOOD - Await all
await Promise.all(ids.map(id => processItem(id).catch(console.error)));

4. Wrong PATCH Semantics

typescript
// BAD - PATCH replacing all data (this is PUT behavior)
await db.delete(topics).where(eq(topics.contactId, id));
await db.insert(topics).values(newTopics);

// GOOD - PATCH adds/updates without removing existing
const existing = await db.select().from(topics).where(...);
const newOnly = topicIds.filter(id => !existing.has(id));
await db.insert(topics).values(newOnly);

Quick Reference

typescript
// Auth context
const authContext = (ctx as unknown as { auth: AuthContext }).auth;
const { organizationId, userId } = authContext;

// Set status code
ctx.set.status = 201;

// Return error
ctx.set.status = 400;
return { error: "Message" };

// Await events
await emitEvent({ ... }).catch(console.error);

// Parallel awaits
await Promise.all([...promises]);