---
title: "Opportunities API"
description: "Öffentliche Browse-Liste plus Suche und CRUD mit Sitzung für SerializedOpportunity, einschließlich der Feed-Felder für Autor und Betrachter"
locale: "de"
---
# Opportunities API

REST für den Marktplatz mit doppelter Natur. Die Browse-Liste ist für Besucher offen; alles andere erfordert eine Sitzung. Die **Browse-Liste** (`GET /api/opportunities`) ist eine rollenbewusste Cursor-Paginierung plus Anreicherung pro Betrachter. Die **Suche** ist eine eigene Route. Like / Save / Hide / Contact sind Server Actions, keine HTTP-Ressourcen.

> **Info**
> Nutze die Tabs **Founder** / **Developer**, um diese Seite zu filtern. Produktkreislauf und Karten-UX: [Opportunities](/docs/features/opportunities.md).

  Ältere API-Texte nannten feste "7 Typen". Laufzeittypen sind die Union `OpportunityType` in `features/opportunities/types/index.ts`, **begrenzt** durch `ring-config.json` → `opportunities.enabledTypes`. Dokumentiere keine geschlossene Siebenermenge.

## Oberflächen (gemeinsam)

| Methode | Route | Authentifizierung | Hinweise |
|---------|-------|-------------------|----------|
| `GET` | `/api/opportunities` | Optional | `limit`, `startAfter`. Die Filterparameter `types`/`type`, `q`/`query`, `categories`/`category`, `location`, `priority` schalten auf den Suchdienst um. Übergibt `viewerUserId`, wenn eine Sitzung besteht. Liefert `{ opportunities, items, lastVisible, cursor, hasMore }` |
| `POST` | `/api/opportunities` | Sitzung | Body `createOpportunityBodySchema` → `{ opportunity }` **201** |
| `GET` | `/api/opportunities/{id}` | Sitzung | Ein Eintrag |
| `PUT` / `PATCH` | `/api/opportunities/{id}` | Sitzung | Aktualisieren |
| `DELETE` | `/api/opportunities/{id}` | Sitzung | Löschen |
| `GET` | `/api/opportunities/search` | Sitzung | Erfordert `q`. Optional `types`, `categories`, `location`, `limit` |
| `POST` | `/api/opportunities/search` | Sitzung | JSON-Body `SearchOpportunitiesParams` |
| `POST` | `/api/opportunities/{id}/sign` | Sitzung | Beratende Petitionsunterschrift, eine pro Nutzer. Liefert `signatureCount`, `signatureTarget` |
| `GET` | `/api/opportunities/my` | Sitzung | Liste des Eigentümers; `view=` `all` \| `drafts` \| `pending` \| `active` \| `archived` |
| `POST` | `/api/opportunities/upload` | Sitzung | Anhänge |
| `GET` / `POST` | `/api/mcp/v1/opportunities` | MCP-Guard | Die Liste übergibt heute **kein** `viewerUserId` |

Seit 2026-10-17 liefert ein nicht authentifiziertes `GET /api/opportunities` öffentliche, nicht vertrauliche Zeilen für die Rolle `visitor`. Der Besucherfilter greift nach dem Abruf der Seite, daher kann eine Gastseite weniger als `limit` Zeilen haben. Detail, Suche und Änderungen liefern ohne Sitzung weiterhin **401**.

### For founders

## Was Betreiber wissen sollten

- **Browsen vs. Suchen.** Die Seite `/opportunities` lädt `GET /api/opportunities` (eine Seite mit Einträgen). Filter im rechten Panel werden als Abfrageparameter gesendet und auf dem Server angewendet. Besucher können ohne Konto browsen.
- **Ausgeblendete Einträge.** Wenn ein Mitglied auf Hide tippt, lässt das nächste Browsen oder Suchen **dieses Kontos** die ID weg. Andere Mitglieder sehen den Eintrag weiterhin.
- **Namen auf Karten.** Antworten von Liste und Suche hängen einen öffentlich sicheren `creator`-Snapshot an, sodass die UI nie eine rohe UUID zeigt.
- **Im Produkt erstellen.** Das Formular zum Hinzufügen von Opportunities bevorzugt weiterhin die Server Action `createOpportunity` (`app/_actions/opportunities.ts`). REST-`POST` ist für API- und MCP-Clients; beide rufen denselben Service auf.

  
- **[Feature-Kreislauf](/docs/features/opportunities.md)** — Matcher, aktivierte Typen, Aktionen der Feed-Karten, My Opportunities.

  
- **[Discovery-Synchronisierung](/docs/architecture/discovery-mutation-sync.md)** — Cache-Tags, revalidatePath, Tunnel `opportunity:*` nach Erstellen, Aktualisieren oder Löschen.

  
- **[API-Beispiele](/docs/examples/api-integration.md)** — curl mit Sitzungs-Cookies auf einem lokalen Klon.

### For developers

## Anreicherung der Liste

`GET /api/opportunities` (`app/api/opportunities/route.ts`):

1. `auth()`. Ohne Sitzung ist die Rolle `visitor`, ohne Betrachter-Anreicherung.
2. Mit Filterparametern: `searchOpportunities`. Sonst `getOpportunitiesForRole({ userRole, limit, startAfter, viewerUserId })`.
3. Wenn `viewerUserId` gesetzt ist: `getViewerHiddenOpportunityIds`, dann der DatabaseService-Filter `{ field: 'id', operator: 'not-in', value: hiddenIds }`.
4. `attachOpportunityFeedFields` hängt `creator`, `viewer`, `likes` an.

`searchOpportunities` liest intern `session.user.id` (oder die ID des MCP-Akteurs); Aufrufer übergeben **kein** `viewerUserId`. Ausschluss ausgeblendeter Einträge und Anreicherung laufen, wenn diese ID existiert.

MCP `GET /api/mcp/v1/opportunities` ruft `getOpportunitiesForRole` **ohne** `viewerUserId` auf (kein Ausblendfilter, kein creator/viewer in dieser Liste).

### Zusätze in `SerializedOpportunity` (Liste/Suche)

{`interface OpportunityCreatorSummary {
  id: string
  name: string
  username?: string  // only when publicProfile
  avatar?: string
}

interface OpportunityViewerFlags {
  liked: boolean
  saved: boolean
  hidden: boolean
}

// on SerializedOpportunity:
creator?: OpportunityCreatorSummary
viewer?: OpportunityViewerFlags
likes?: number           // denormalized count on the JSONB row
createdBy: string        // user id (always)
metadata?: { source?: { kind: 'chat_task'; messageId?: string; conversationId?: string } }`}

Kanonische Typdatei: `features/opportunities/types/index.ts`. Datumswerte sind ISO-Strings. Firestore-`Timestamp`-Formen existieren nur für `DB_BACKEND_MODE=firebase-full`.

### Create-Body (Pflicht)

`createOpportunityBodySchema` (`features/opportunities/lib/create-opportunity-schema.ts`): `title`, `briefDescription`, `organizationId`, `category`, `location`, `visibility`, `contactInfo.linkedEntity`, `contactInfo.contactAccount`.

`POST` liefert **201** `{ opportunity }`. Danach ruft das Erstellen `syncOpportunityDiscovery` mit einem Fragment auf, das `creator { id, name, avatar }` aus der Sitzung enthält (live eingefügte Karten).

### Interaktionen (kein REST)

| Aktion | Modul |
|--------|-------|
| Like | `toggleOpportunityLike` → `like-service` / `likes` |
| Save | `toggleOpportunitySave` → `user_content_interactions` |
| Hide | `markOpportunityNotInterested` → `user_content_interactions` |
| Kommentar | `createOpportunityComment` |
| Kontaktabsicht | `recordOpportunityContactIntent` (Matcher; die UI macht weiterhin `router.push` zu `/messages?user=`) |

Datei: `app/_actions/opportunity-interactions.ts`.

### Mapping und `not-in`

`mapDbQueryRowToSerializedOpportunity()` in `features/opportunities/lib/opportunity-db-mapper.ts`. Nutze `lib/converters/opportunity-converter.ts` **nicht** auf Postgres.

Der Operator `'not-in'` steht in `IDatabaseService` `DatabaseFilterOperator`. PostgreSQL: `NOT (field = ANY($n))`. Der Firebase-Adapter filtert nachträglich.

### Discovery-Synchronisierung nach Änderungen

Erstellen, Aktualisieren, Löschen und Statusänderungen rufen `syncOpportunityDiscovery()` auf, das:

1. **`revalidateTag`**: invalidiert die Opportunity-Listen von `unstable_cache` (`lib/cached-data.ts`)
2. **`revalidatePath`**: `/[locale]/opportunities`, `/[locale]/opportunities/[id]`, `/[locale]/opportunities/my`
3. **Tunnel**: `opportunity:created|updated|deleted` auf dem Kanal `opportunities`

Es gibt keinen separaten Befehl zum Neuindexieren der Suche. Siehe [Discovery mutation sync](/docs/architecture/discovery-mutation-sync.md). Das Browsen im Client nutzt weiterhin `useCursorFeed` (24 h); `revalidatePath` allein leert diesen Cache nicht.

### Beispiele

{`const response = await fetch('/api/opportunities?limit=10', { credentials: 'include' })
const { opportunities, lastVisible, hasMore } = await response.json()
// opportunities[i].creator?.name
// opportunities[i].viewer?.saved`}

{`const response = await fetch('/api/opportunities/search?q=react&limit=20', {
  credentials: 'include',
})
const { opportunities, totalCount, searchMetadata } = await response.json()`}

Erstellen in der UI: `useActionState` + `createOpportunity` aus `app/_actions/opportunities.ts`, siehe [API integration](/docs/examples/api-integration.md).

## Zugriffskontrolle

- **Sichtbarkeit** im Dokument: `public` \| `subscriber` \| `member` \| `confidential` \| `entity`. `entity`-Zeilen sind nur für Admins, den Autor und Mitglieder der verknüpften Entity lesbar. Listenabfragen wenden Sichtbarkeitsfilter nach Rolle an (`buildOpportunityVisibilityFilters`).
- **Vertrauliche** Einträge erfordern Rollen mit vertraulichem Zugriff.
- **Bewerben** passiert in der App (in der Alpha gibt es keine eigene REST-Route `/apply`).

## Häufige Fragen

### Auswirkungen

#### Kann ich Opportunities ohne Sitzung auflisten?
Ja, nur öffentliche, nicht vertrauliche Zeilen. `GET /api/opportunities/{id}` und die Suche erfordern weiterhin eine Sitzung.

#### Respektiert die Suche Hide?
Ja, wenn `searchOpportunities` die Nutzer-ID der Sitzung (oder des MCP-Akteurs) hat. Die HTTP-Suchrouten GET/POST erfordern eine Sitzung.

### Migration

#### Ist `createdBy` weg?
Nein. Es ist weiterhin die Nutzer-ID. `creator` ist ein optionaler öffentlicher Snapshot für die UI.

### Betrieb

#### Warum zeigen MCP-Listen noch UUIDs auf Karten?
Die MCP-Liste lässt `viewerUserId` weg, daher greift `attachOpportunityFeedFields` auf diesem Pfad nicht.

## Verwandte Dokumentation

  
- [features/opportunities](/docs/features/opportunities.md) — Voraussetzung: Produktkreislauf, Aktionen der Feed-Karten und Grenzen der aktivierten Typen.

  
- [architecture/discovery-mutation-sync](/docs/architecture/discovery-mutation-sync.md) — Hängt ab von: Cache-Tags, revalidatePath und Tunnel opportunity:* nach Änderungen.

  
- [features/tasks](/docs/features/tasks.md) — Gleicher Ablauf: Umwandeln in ein Gesuch schreibt metadata.source, das die Listenkarten lesen.

  
- [api/entities](/docs/api/entities.md) — Gleicher Ablauf: Die Verknüpfung mit der Organisation ist beim Erstellen Pflicht (`organizationId` / linkedEntity).

  
- [examples/api-integration](/docs/examples/api-integration.md) — Nächster Schritt: curl mit einem lokalen Sitzungs-Cookie.

  
- [api/authentication](/docs/api/authentication.md) — Hängt ab von: Sitzungs-Cookie für jede Opportunities-Route erforderlich.
