Compare commits

...

3 Commits

Author SHA1 Message Date
Jiang Bohan
7bbf11fff7 feat(invitation): send email notification when inviting a user
Uses the existing Resend email service to notify invitees.
Email includes inviter name, workspace name, and a link to the app.
Sent fire-and-forget in a goroutine to avoid blocking the API response.
2026-04-15 00:12:59 +08:00
Jiang Bohan
67afc1c4bd fix(invitation): address PR review nits
- Fix invitation:revoked listener to send event to invitee user (was no-op)
- Remove duplicate queryClient2 in app-sidebar.tsx, reuse existing queryClient
- Add expires_at > now() filter to ListPendingInvitationsByWorkspace query
2026-04-14 23:59:49 +08:00
Jiang Bohan
12d05b8c6e feat(security): replace instant member-add with invitation acceptance flow
Users invited to a workspace must now explicitly accept the invitation
before becoming a member. This fixes the security vulnerability where
knowing someone's email was enough to auto-register their runtime to
your workspace.

Changes:
- Add workspace_invitation table with pending/accepted/declined/expired states
- Replace CreateMember with CreateInvitation (same endpoint, new behavior)
- Add accept/decline/revoke/list invitation API endpoints
- Add invitation WS events for real-time notification
- Frontend: invitation accept/decline UI in workspace switcher
- Frontend: pending invitations section in members settings tab
2026-04-14 23:50:27 +08:00
18 changed files with 1160 additions and 19 deletions

View File

@@ -52,6 +52,7 @@ import type {
CreatePinRequest,
PinnedItemType,
ReorderPinsRequest,
Invitation,
} from "../types";
import { type Logger, noopLogger } from "../logger";
import { createRequestId } from "../utils";
@@ -551,7 +552,7 @@ export class ApiClient {
return this.fetch(`/api/workspaces/${workspaceId}/members`);
}
async createMember(workspaceId: string, data: CreateMemberRequest): Promise<MemberWithUser> {
async createMember(workspaceId: string, data: CreateMemberRequest): Promise<Invitation> {
return this.fetch(`/api/workspaces/${workspaceId}/members`, {
method: "POST",
body: JSON.stringify(data),
@@ -577,6 +578,33 @@ export class ApiClient {
});
}
// Invitations
async listWorkspaceInvitations(workspaceId: string): Promise<Invitation[]> {
return this.fetch(`/api/workspaces/${workspaceId}/invitations`);
}
async revokeInvitation(workspaceId: string, invitationId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}/invitations/${invitationId}`, {
method: "DELETE",
});
}
async listMyInvitations(): Promise<Invitation[]> {
return this.fetch("/api/invitations");
}
async acceptInvitation(invitationId: string): Promise<MemberWithUser> {
return this.fetch(`/api/invitations/${invitationId}/accept`, {
method: "POST",
});
}
async declineInvitation(invitationId: string): Promise<void> {
await this.fetch(`/api/invitations/${invitationId}/decline`, {
method: "POST",
});
}
async deleteWorkspace(workspaceId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}`, {
method: "DELETE",

View File

@@ -44,6 +44,7 @@ import type {
TaskCompletedPayload,
TaskFailedPayload,
ChatDonePayload,
InvitationCreatedPayload,
} from "../types";
const chatWsLogger = createLogger("chat.ws");
@@ -286,13 +287,42 @@ export function useRealtimeSync(
const myUserId = authStore.getState().user?.id;
if (member.user_id === myUserId) {
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
qc.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
onToast?.(
`You were invited to ${workspace_name ?? "a workspace"}`,
`You joined ${workspace_name ?? "a workspace"}`,
"info",
);
}
});
// invitation:created — notify the invitee of a new pending invitation
const unsubInvitationCreated = ws.on("invitation:created", (p) => {
const { workspace_name } = p as InvitationCreatedPayload;
qc.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
onToast?.(
`You were invited to ${workspace_name ?? "a workspace"}`,
"info",
);
});
// invitation:accepted / declined / revoked — refresh invitation lists
const unsubInvitationAccepted = ws.on("invitation:accepted", () => {
const currentWsId = workspaceStore.getState().workspace?.id;
if (currentWsId) {
qc.invalidateQueries({ queryKey: workspaceKeys.invitations(currentWsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.members(currentWsId) });
}
});
const unsubInvitationDeclined = ws.on("invitation:declined", () => {
const currentWsId = workspaceStore.getState().workspace?.id;
if (currentWsId) {
qc.invalidateQueries({ queryKey: workspaceKeys.invitations(currentWsId) });
}
});
const unsubInvitationRevoked = ws.on("invitation:revoked", () => {
qc.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
});
// --- Chat / task events (global, survives ChatWindow unmount) ---
//
// Single source of truth: the Query cache. No Zustand writes here — the
@@ -409,6 +439,10 @@ export function useRealtimeSync(
unsubWsDeleted();
unsubMemberRemoved();
unsubMemberAdded();
unsubInvitationCreated();
unsubInvitationAccepted();
unsubInvitationDeclined();
unsubInvitationRevoked();
unsubTaskMessage();
unsubChatMessage();
unsubChatDone();

View File

@@ -3,7 +3,7 @@ import type { Agent } from "./agent";
import type { InboxItem } from "./inbox";
import type { Comment, Reaction } from "./comment";
import type { TimelineEntry } from "./activity";
import type { Workspace, MemberWithUser } from "./workspace";
import type { Workspace, MemberWithUser, Invitation } from "./workspace";
import type { Project } from "./project";
// WebSocket event types (matching Go server protocol/events.go)
@@ -53,7 +53,11 @@ export type WSEventType =
| "project:updated"
| "project:deleted"
| "pin:created"
| "pin:deleted";
| "pin:deleted"
| "invitation:created"
| "invitation:accepted"
| "invitation:declined"
| "invitation:revoked";
export interface WSMessage<T = unknown> {
type: WSEventType;
@@ -259,3 +263,23 @@ export interface ProjectUpdatedPayload {
export interface ProjectDeletedPayload {
project_id: string;
}
export interface InvitationCreatedPayload {
invitation: Invitation;
workspace_name?: string;
}
export interface InvitationAcceptedPayload {
invitation_id: string;
member: MemberWithUser;
}
export interface InvitationDeclinedPayload {
invitation_id: string;
invitee_email: string;
}
export interface InvitationRevokedPayload {
invitation_id: string;
invitee_email: string;
}

View File

@@ -22,7 +22,7 @@ export type {
RuntimeUpdateStatus,
IssueUsageSummary,
} from "./agent";
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser } from "./workspace";
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser, Invitation } from "./workspace";
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
export type { Comment, CommentType, CommentAuthorType, Reaction } from "./comment";
export type { TimelineEntry, AssigneeFrequencyEntry } from "./activity";

View File

@@ -45,3 +45,19 @@ export interface MemberWithUser {
email: string;
avatar_url: string | null;
}
export interface Invitation {
id: string;
workspace_id: string;
inviter_id: string;
invitee_email: string;
invitee_user_id: string | null;
role: MemberRole;
status: "pending" | "accepted" | "declined" | "expired";
created_at: string;
updated_at: string;
expires_at: string;
inviter_name?: string;
inviter_email?: string;
workspace_name?: string;
}

View File

@@ -5,6 +5,8 @@ export const workspaceKeys = {
all: (wsId: string) => ["workspaces", wsId] as const,
list: () => ["workspaces", "list"] as const,
members: (wsId: string) => ["workspaces", wsId, "members"] as const,
invitations: (wsId: string) => ["workspaces", wsId, "invitations"] as const,
myInvitations: () => ["invitations", "mine"] as const,
agents: (wsId: string) => ["workspaces", wsId, "agents"] as const,
skills: (wsId: string) => ["workspaces", wsId, "skills"] as const,
assigneeFrequency: (wsId: string) => ["workspaces", wsId, "assignee-frequency"] as const,
@@ -39,6 +41,20 @@ export function skillListOptions(wsId: string) {
});
}
export function invitationListOptions(wsId: string) {
return queryOptions({
queryKey: workspaceKeys.invitations(wsId),
queryFn: () => api.listWorkspaceInvitations(wsId),
});
}
export function myInvitationListOptions() {
return queryOptions({
queryKey: workspaceKeys.myInvitations(),
queryFn: () => api.listMyInvitations(),
});
}
export function assigneeFrequencyOptions(wsId: string) {
return queryOptions({
queryKey: workspaceKeys.assigneeFrequency(wsId),

View File

@@ -58,8 +58,8 @@ import {
} from "@multica/ui/components/ui/dropdown-menu";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { workspaceListOptions } from "@multica/core/workspace/queries";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { workspaceListOptions, myInvitationListOptions, workspaceKeys } from "@multica/core/workspace/queries";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { inboxKeys, deduplicateInboxItems } from "@multica/core/inbox/queries";
import { api } from "@multica/core/api";
import { useModalStore } from "@multica/core/modals";
@@ -164,6 +164,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
const workspace = useWorkspaceStore((s) => s.workspace);
const switchWorkspace = useWorkspaceStore((s) => s.switchWorkspace);
const { data: workspaces = [] } = useQuery(workspaceListOptions());
const { data: myInvitations = [] } = useQuery(myInvitationListOptions());
const wsId = workspace?.id;
const { data: inboxItems = [] } = useQuery({
@@ -197,6 +198,19 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
);
const queryClient = useQueryClient();
const acceptInvitationMut = useMutation({
mutationFn: (id: string) => api.acceptInvitation(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
queryClient.invalidateQueries({ queryKey: workspaceKeys.list() });
},
});
const declineInvitationMut = useMutation({
mutationFn: (id: string) => api.declineInvitation(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
},
});
const logout = () => {
queryClient.clear();
authLogout();
@@ -287,6 +301,44 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
Create workspace
</DropdownMenuItem>
</DropdownMenuGroup>
{myInvitations.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="text-xs text-muted-foreground">
Pending invitations
</DropdownMenuLabel>
{myInvitations.map((inv) => (
<div key={inv.id} className="flex items-center gap-2 px-2 py-1.5">
<WorkspaceAvatar name={inv.workspace_name ?? "W"} size="sm" />
<span className="flex-1 truncate text-sm">{inv.workspace_name ?? "Workspace"}</span>
<button
type="button"
className="text-xs px-2 py-0.5 rounded bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
disabled={acceptInvitationMut.isPending}
onClick={(e) => {
e.stopPropagation();
acceptInvitationMut.mutate(inv.id);
}}
>
Join
</button>
<button
type="button"
className="text-xs px-2 py-0.5 rounded bg-muted text-muted-foreground hover:bg-muted/80 disabled:opacity-50"
disabled={declineInvitationMut.isPending}
onClick={(e) => {
e.stopPropagation();
declineInvitationMut.mutate(inv.id);
}}
>
Decline
</button>
</div>
))}
</DropdownMenuGroup>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem variant="destructive" onClick={logout}>

View File

@@ -1,9 +1,9 @@
"use client";
import { useState } from "react";
import { Crown, Shield, User, Plus, MoreHorizontal, UserMinus, Users } from "lucide-react";
import { Crown, Shield, User, Plus, MoreHorizontal, UserMinus, Users, Clock, X, Mail } from "lucide-react";
import { ActorAvatar } from "../../common/actor-avatar";
import type { MemberWithUser, MemberRole } from "@multica/core/types";
import type { MemberWithUser, MemberRole, Invitation } from "@multica/core/types";
import { Input } from "@multica/ui/components/ui/input";
import { Button } from "@multica/ui/components/ui/button";
import { Card, CardContent } from "@multica/ui/components/ui/card";
@@ -40,7 +40,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { useWorkspaceId } from "@multica/core/hooks";
import { memberListOptions, workspaceKeys } from "@multica/core/workspace/queries";
import { memberListOptions, invitationListOptions, workspaceKeys } from "@multica/core/workspace/queries";
import { api } from "@multica/core/api";
const roleConfig: Record<MemberRole, { label: string; icon: typeof Crown; description: string }> = {
@@ -140,17 +140,62 @@ function MemberRow({
);
}
function InvitationRow({
invitation,
canManage,
onRevoke,
busy,
}: {
invitation: Invitation;
canManage: boolean;
onRevoke: () => void;
busy: boolean;
}) {
const rc = roleConfig[invitation.role];
return (
<div className="flex items-center gap-3 px-4 py-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted">
<Mail className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{invitation.invitee_email}</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
<span>Pending</span>
</div>
</div>
{canManage && (
<Button
variant="ghost"
size="icon-sm"
disabled={busy}
onClick={onRevoke}
title="Revoke invitation"
>
<X className="h-4 w-4 text-muted-foreground" />
</Button>
)}
<Badge variant="outline">
{rc.label}
</Badge>
</div>
);
}
export function MembersTab() {
const user = useAuthStore((s) => s.user);
const workspace = useWorkspaceStore((s) => s.workspace);
const qc = useQueryClient();
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: invitations = [] } = useQuery(invitationListOptions(wsId));
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState<MemberRole>("member");
const [inviteLoading, setInviteLoading] = useState(false);
const [memberActionId, setMemberActionId] = useState<string | null>(null);
const [invitationActionId, setInvitationActionId] = useState<string | null>(null);
const [confirmAction, setConfirmAction] = useState<{
title: string;
description: string;
@@ -162,7 +207,7 @@ export function MembersTab() {
const canManageWorkspace = currentMember?.role === "owner" || currentMember?.role === "admin";
const isOwner = currentMember?.role === "owner";
const handleAddMember = async () => {
const handleInviteMember = async () => {
if (!workspace) return;
setInviteLoading(true);
try {
@@ -172,15 +217,36 @@ export function MembersTab() {
});
setInviteEmail("");
setInviteRole("member");
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
toast.success("Member added");
qc.invalidateQueries({ queryKey: workspaceKeys.invitations(wsId) });
toast.success("Invitation sent");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to add member");
toast.error(e instanceof Error ? e.message : "Failed to send invitation");
} finally {
setInviteLoading(false);
}
};
const handleRevokeInvitation = (invitation: Invitation) => {
if (!workspace) return;
setConfirmAction({
title: "Revoke invitation",
description: `Revoke the invitation to ${invitation.invitee_email}? They will no longer be able to join this workspace.`,
variant: "destructive",
onConfirm: async () => {
setInvitationActionId(invitation.id);
try {
await api.revokeInvitation(workspace.id, invitation.id);
qc.invalidateQueries({ queryKey: workspaceKeys.invitations(wsId) });
toast.success("Invitation revoked");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to revoke invitation");
} finally {
setInvitationActionId(null);
}
},
});
};
const handleRoleChange = async (memberId: string, role: MemberRole) => {
if (!workspace) return;
setMemberActionId(memberId);
@@ -231,7 +297,7 @@ export function MembersTab() {
<CardContent className="space-y-3">
<div className="flex items-center gap-2">
<Plus className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-medium">Add member</h3>
<h3 className="text-sm font-medium">Invite member</h3>
</div>
<div className="grid gap-3 sm:grid-cols-[1fr_120px_auto]">
<Input
@@ -239,20 +305,22 @@ export function MembersTab() {
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="user@company.com"
onKeyDown={(e) => {
if (e.key === "Enter" && inviteEmail.trim()) handleInviteMember();
}}
/>
<Select value={inviteRole} onValueChange={(value) => setInviteRole(value as MemberRole)}>
<SelectTrigger size="sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
{isOwner && <SelectItem value="owner">Owner</SelectItem>}
</SelectContent>
</Select>
<Button
onClick={handleAddMember}
onClick={handleInviteMember}
disabled={inviteLoading || !inviteEmail.trim()}
>
{inviteLoading ? "Adding..." : "Add"}
{inviteLoading ? "Inviting..." : "Invite"}
</Button>
</div>
</CardContent>
@@ -280,6 +348,27 @@ export function MembersTab() {
)}
</section>
{invitations.length > 0 && (
<section className="space-y-4">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">Pending invitations ({invitations.length})</h2>
</div>
<div className="overflow-hidden rounded-xl ring-1 ring-foreground/10">
{invitations.map((inv, i) => (
<div key={inv.id} className={i > 0 ? "border-t border-border/50" : ""}>
<InvitationRow
invitation={inv}
canManage={canManageWorkspace}
onRevoke={() => handleRevokeInvitation(inv)}
busy={invitationActionId === inv.id}
/>
</div>
))}
</div>
</section>
)}
<AlertDialog open={!!confirmAction} onOpenChange={(v) => { if (!v) setConfirmAction(null); }}>
<AlertDialogContent>
<AlertDialogHeader>

View File

@@ -23,6 +23,8 @@ func registerListeners(bus *events.Bus, hub *realtime.Hub) {
protocol.EventInboxArchived: true,
protocol.EventInboxBatchRead: true,
protocol.EventInboxBatchArchived: true,
protocol.EventInvitationCreated: true,
protocol.EventInvitationRevoked: true,
}
// Helper: marshal event and send to a specific user.
@@ -67,6 +69,47 @@ func registerListeners(bus *events.Bus, hub *realtime.Hub) {
})
}
// invitation:created — send to the invitee so they see the invitation in real time.
bus.Subscribe(protocol.EventInvitationCreated, func(e events.Event) {
payload, ok := e.Payload.(map[string]any)
if !ok {
return
}
inv, ok := payload["invitation"].(handler.InvitationResponse)
if !ok {
// Fallback for map encoding.
if invMap, ok := payload["invitation"].(map[string]any); ok {
if uid, _ := invMap["invitee_user_id"].(*string); uid != nil && *uid != "" {
data, err := json.Marshal(map[string]any{"type": e.Type, "payload": e.Payload, "actor_id": e.ActorID})
if err != nil {
return
}
hub.SendToUser(*uid, data)
}
}
return
}
if inv.InviteeUserID != nil && *inv.InviteeUserID != "" {
data, err := json.Marshal(map[string]any{"type": e.Type, "payload": e.Payload, "actor_id": e.ActorID})
if err != nil {
return
}
hub.SendToUser(*inv.InviteeUserID, data)
}
})
// invitation:revoked — send to the invitee so their pending list updates.
bus.Subscribe(protocol.EventInvitationRevoked, func(e events.Event) {
payload, ok := e.Payload.(map[string]any)
if !ok {
return
}
uid, _ := payload["invitee_user_id"].(*string)
if uid != nil && *uid != "" {
sendToRecipient(hub, e, *uid)
}
})
// member:added — also send to the invited user so they discover the new workspace.
// Pass excludeWorkspace so clients already in the target room (reached via
// BroadcastToWorkspace in SubscribeAll) don't receive the event twice.

View File

@@ -166,23 +166,30 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
r.Get("/", h.GetWorkspace)
r.Get("/members", h.ListMembersWithUser)
r.Post("/leave", h.LeaveWorkspace)
r.Get("/invitations", h.ListWorkspaceInvitations)
})
// Admin-level access
r.Group(func(r chi.Router) {
r.Use(middleware.RequireWorkspaceRoleFromURL(queries, "id", "owner", "admin"))
r.Put("/", h.UpdateWorkspace)
r.Patch("/", h.UpdateWorkspace)
r.Post("/members", h.CreateMember)
r.Post("/members", h.CreateInvitation)
r.Route("/members/{memberId}", func(r chi.Router) {
r.Patch("/", h.UpdateMember)
r.Delete("/", h.DeleteMember)
})
r.Delete("/invitations/{invitationId}", h.RevokeInvitation)
})
// Owner-only access
r.With(middleware.RequireWorkspaceRoleFromURL(queries, "id", "owner")).Delete("/", h.DeleteWorkspace)
})
})
// User-scoped invitation routes (no workspace context required)
r.Get("/api/invitations", h.ListMyInvitations)
r.Post("/api/invitations/{id}/accept", h.AcceptInvitation)
r.Post("/api/invitations/{id}/decline", h.DeclineInvitation)
r.Route("/api/tokens", func(r chi.Router) {
r.Get("/", h.ListPersonalAccessTokens)
r.Post("/", h.CreatePersonalAccessToken)

View File

@@ -0,0 +1,420 @@
package handler
import (
"encoding/json"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/logger"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
// InvitationResponse is the JSON shape returned for a workspace invitation.
type InvitationResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
InviterID string `json:"inviter_id"`
InviteeEmail string `json:"invitee_email"`
InviteeUserID *string `json:"invitee_user_id"`
Role string `json:"role"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ExpiresAt string `json:"expires_at"`
// Enriched fields (present in list responses).
InviterName string `json:"inviter_name,omitempty"`
InviterEmail string `json:"inviter_email,omitempty"`
WorkspaceName string `json:"workspace_name,omitempty"`
}
func invitationToResponse(inv db.WorkspaceInvitation) InvitationResponse {
return InvitationResponse{
ID: uuidToString(inv.ID),
WorkspaceID: uuidToString(inv.WorkspaceID),
InviterID: uuidToString(inv.InviterID),
InviteeEmail: inv.InviteeEmail,
InviteeUserID: uuidToPtr(inv.InviteeUserID),
Role: inv.Role,
Status: inv.Status,
CreatedAt: timestampToString(inv.CreatedAt),
UpdatedAt: timestampToString(inv.UpdatedAt),
ExpiresAt: timestampToString(inv.ExpiresAt),
}
}
// ---------------------------------------------------------------------------
// CreateInvitation replaces the old "instant-add" CreateMember flow.
// POST /api/workspaces/{id}/members (same endpoint, new behaviour)
// ---------------------------------------------------------------------------
func (h *Handler) CreateInvitation(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "id")
requester, ok := h.workspaceMember(w, r, workspaceID)
if !ok {
return
}
var req CreateMemberRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
email := strings.ToLower(strings.TrimSpace(req.Email))
if email == "" {
writeError(w, http.StatusBadRequest, "email is required")
return
}
role, valid := normalizeMemberRole(req.Role)
if !valid {
writeError(w, http.StatusBadRequest, "invalid member role")
return
}
if role == "owner" {
writeError(w, http.StatusBadRequest, "cannot invite as owner")
return
}
// Check if the user is already a member.
existingUser, err := h.Queries.GetUserByEmail(r.Context(), email)
if err == nil {
_, memberErr := h.Queries.GetMemberByUserAndWorkspace(r.Context(), db.GetMemberByUserAndWorkspaceParams{
UserID: existingUser.ID,
WorkspaceID: parseUUID(workspaceID),
})
if memberErr == nil {
writeError(w, http.StatusConflict, "user is already a member")
return
}
}
// Check if there is already a pending invitation.
_, err = h.Queries.GetPendingInvitationByEmail(r.Context(), db.GetPendingInvitationByEmailParams{
WorkspaceID: parseUUID(workspaceID),
InviteeEmail: email,
})
if err == nil {
writeError(w, http.StatusConflict, "invitation already pending for this email")
return
}
// Resolve invitee_user_id if the user already exists.
var inviteeUserID pgtype.UUID
if existingUser.ID.Valid {
inviteeUserID = existingUser.ID
}
inv, err := h.Queries.CreateInvitation(r.Context(), db.CreateInvitationParams{
WorkspaceID: parseUUID(workspaceID),
InviterID: requester.UserID,
InviteeEmail: email,
InviteeUserID: inviteeUserID,
Role: role,
})
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "invitation already pending for this email")
return
}
slog.Warn("create invitation failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID, "email", email)...)
writeError(w, http.StatusInternalServerError, "failed to create invitation")
return
}
slog.Info("invitation created", append(logger.RequestAttrs(r), "invitation_id", uuidToString(inv.ID), "workspace_id", workspaceID, "email", email, "role", role)...)
resp := invitationToResponse(inv)
// Notify the invitee in real time if they are a registered user.
userID := requestUserID(r)
eventPayload := map[string]any{"invitation": resp}
var workspaceName string
if ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(workspaceID)); err == nil {
workspaceName = ws.Name
eventPayload["workspace_name"] = ws.Name
}
h.publish(protocol.EventInvitationCreated, workspaceID, "member", userID, eventPayload)
// Send invitation email (fire-and-forget).
if h.EmailService != nil && workspaceName != "" {
inviterName := email // fallback
if inviter, err := h.Queries.GetUser(r.Context(), requester.UserID); err == nil {
inviterName = inviter.Name
}
go func() {
if err := h.EmailService.SendInvitationEmail(email, inviterName, workspaceName); err != nil {
slog.Warn("failed to send invitation email", "email", email, "error", err)
}
}()
}
writeJSON(w, http.StatusCreated, resp)
}
// ---------------------------------------------------------------------------
// ListWorkspaceInvitations — pending invitations for a workspace (admin view).
// GET /api/workspaces/{id}/invitations
// ---------------------------------------------------------------------------
func (h *Handler) ListWorkspaceInvitations(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "id")
rows, err := h.Queries.ListPendingInvitationsByWorkspace(r.Context(), parseUUID(workspaceID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list invitations")
return
}
resp := make([]InvitationResponse, len(rows))
for i, row := range rows {
resp[i] = InvitationResponse{
ID: uuidToString(row.ID),
WorkspaceID: uuidToString(row.WorkspaceID),
InviterID: uuidToString(row.InviterID),
InviteeEmail: row.InviteeEmail,
InviteeUserID: uuidToPtr(row.InviteeUserID),
Role: row.Role,
Status: row.Status,
CreatedAt: timestampToString(row.CreatedAt),
UpdatedAt: timestampToString(row.UpdatedAt),
ExpiresAt: timestampToString(row.ExpiresAt),
InviterName: row.InviterName,
InviterEmail: row.InviterEmail,
}
}
writeJSON(w, http.StatusOK, resp)
}
// ---------------------------------------------------------------------------
// RevokeInvitation — admin cancels a pending invitation.
// DELETE /api/workspaces/{id}/invitations/{invitationId}
// ---------------------------------------------------------------------------
func (h *Handler) RevokeInvitation(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "id")
invitationID := chi.URLParam(r, "invitationId")
inv, err := h.Queries.GetInvitation(r.Context(), parseUUID(invitationID))
if err != nil || uuidToString(inv.WorkspaceID) != workspaceID || inv.Status != "pending" {
writeError(w, http.StatusNotFound, "invitation not found")
return
}
if err := h.Queries.RevokeInvitation(r.Context(), inv.ID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to revoke invitation")
return
}
slog.Info("invitation revoked", "invitation_id", invitationID, "workspace_id", workspaceID)
userID := requestUserID(r)
h.publish(protocol.EventInvitationRevoked, workspaceID, "member", userID, map[string]any{
"invitation_id": invitationID,
"invitee_email": inv.InviteeEmail,
"invitee_user_id": uuidToPtr(inv.InviteeUserID),
})
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// ListMyInvitations — current user's pending invitations across all workspaces.
// GET /api/invitations
// ---------------------------------------------------------------------------
func (h *Handler) ListMyInvitations(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
user, err := h.Queries.GetUser(r.Context(), parseUUID(userID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load user")
return
}
rows, err := h.Queries.ListPendingInvitationsForUser(r.Context(), db.ListPendingInvitationsForUserParams{
InviteeUserID: user.ID,
InviteeEmail: user.Email,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list invitations")
return
}
resp := make([]InvitationResponse, len(rows))
for i, row := range rows {
resp[i] = InvitationResponse{
ID: uuidToString(row.ID),
WorkspaceID: uuidToString(row.WorkspaceID),
InviterID: uuidToString(row.InviterID),
InviteeEmail: row.InviteeEmail,
InviteeUserID: uuidToPtr(row.InviteeUserID),
Role: row.Role,
Status: row.Status,
CreatedAt: timestampToString(row.CreatedAt),
UpdatedAt: timestampToString(row.UpdatedAt),
ExpiresAt: timestampToString(row.ExpiresAt),
WorkspaceName: row.WorkspaceName,
InviterName: row.InviterName,
InviterEmail: row.InviterEmail,
}
}
writeJSON(w, http.StatusOK, resp)
}
// ---------------------------------------------------------------------------
// AcceptInvitation — user accepts a pending invitation.
// POST /api/invitations/{id}/accept
// ---------------------------------------------------------------------------
func (h *Handler) AcceptInvitation(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
invitationID := chi.URLParam(r, "id")
inv, err := h.Queries.GetInvitation(r.Context(), parseUUID(invitationID))
if err != nil {
writeError(w, http.StatusNotFound, "invitation not found")
return
}
// Verify the invitation belongs to the current user.
user, err := h.Queries.GetUser(r.Context(), parseUUID(userID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load user")
return
}
if strings.ToLower(user.Email) != inv.InviteeEmail && uuidToString(inv.InviteeUserID) != userID {
writeError(w, http.StatusForbidden, "invitation does not belong to you")
return
}
if inv.Status != "pending" {
writeError(w, http.StatusBadRequest, "invitation is not pending")
return
}
// Check expiry.
if inv.ExpiresAt.Valid && inv.ExpiresAt.Time.Before(time.Now()) {
writeError(w, http.StatusGone, "invitation has expired")
return
}
// Use a transaction: mark accepted + create member atomically.
tx, err := h.TxStarter.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to accept invitation")
return
}
defer tx.Rollback(r.Context())
qtx := h.Queries.WithTx(tx)
accepted, err := qtx.AcceptInvitation(r.Context(), inv.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to accept invitation")
return
}
member, err := qtx.CreateMember(r.Context(), db.CreateMemberParams{
WorkspaceID: accepted.WorkspaceID,
UserID: user.ID,
Role: accepted.Role,
})
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "you are already a member of this workspace")
return
}
writeError(w, http.StatusInternalServerError, "failed to create membership")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "failed to accept invitation")
return
}
slog.Info("invitation accepted", "invitation_id", invitationID, "user_id", userID, "workspace_id", uuidToString(accepted.WorkspaceID))
wsID := uuidToString(accepted.WorkspaceID)
memberResp := memberWithUserResponse(member, user)
// Broadcast member:added so existing clients update their member lists.
eventPayload := map[string]any{"member": memberResp}
if ws, err := h.Queries.GetWorkspace(r.Context(), accepted.WorkspaceID); err == nil {
eventPayload["workspace_name"] = ws.Name
}
h.publish(protocol.EventMemberAdded, wsID, "member", userID, eventPayload)
// Notify the workspace about the acceptance.
h.publish(protocol.EventInvitationAccepted, wsID, "member", userID, map[string]any{
"invitation_id": invitationID,
"member": memberResp,
})
writeJSON(w, http.StatusOK, memberResp)
}
// ---------------------------------------------------------------------------
// DeclineInvitation — user declines a pending invitation.
// POST /api/invitations/{id}/decline
// ---------------------------------------------------------------------------
func (h *Handler) DeclineInvitation(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
invitationID := chi.URLParam(r, "id")
inv, err := h.Queries.GetInvitation(r.Context(), parseUUID(invitationID))
if err != nil {
writeError(w, http.StatusNotFound, "invitation not found")
return
}
// Verify the invitation belongs to the current user.
user, err := h.Queries.GetUser(r.Context(), parseUUID(userID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load user")
return
}
if strings.ToLower(user.Email) != inv.InviteeEmail && uuidToString(inv.InviteeUserID) != userID {
writeError(w, http.StatusForbidden, "invitation does not belong to you")
return
}
if inv.Status != "pending" {
writeError(w, http.StatusBadRequest, "invitation is not pending")
return
}
declined, err := h.Queries.DeclineInvitation(r.Context(), inv.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to decline invitation")
return
}
slog.Info("invitation declined", "invitation_id", invitationID, "user_id", userID)
wsID := uuidToString(declined.WorkspaceID)
h.publish(protocol.EventInvitationDeclined, wsID, "member", userID, map[string]any{
"invitation_id": invitationID,
"invitee_email": declined.InviteeEmail,
})
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -3,6 +3,7 @@ package service
import (
"fmt"
"os"
"strings"
"github.com/resend/resend-go/v2"
)
@@ -52,3 +53,36 @@ func (s *EmailService) SendVerificationCode(to, code string) error {
_, err := s.client.Emails.Send(params)
return err
}
// SendInvitationEmail notifies the invitee that they have been invited to a workspace.
func (s *EmailService) SendInvitationEmail(to, inviterName, workspaceName string) error {
// Build the app URL for the invitation — users will see pending invitations
// in the workspace switcher after logging in.
appURL := strings.TrimSpace(os.Getenv("FRONTEND_ORIGIN"))
if appURL == "" {
appURL = "https://app.multica.ai"
}
if s.client == nil {
fmt.Printf("[DEV] Invitation email to %s: %s invited you to %s — %s\n", to, inviterName, workspaceName, appURL)
return nil
}
params := &resend.SendEmailRequest{
From: s.fromEmail,
To: []string{to},
Subject: fmt.Sprintf("%s invited you to %s on Multica", inviterName, workspaceName),
Html: fmt.Sprintf(
`<div style="font-family: sans-serif; max-width: 480px; margin: 0 auto;">
<h2>You're invited to join %s</h2>
<p><strong>%s</strong> invited you to collaborate in the <strong>%s</strong> workspace on Multica.</p>
<p style="margin: 24px 0;">
<a href="%s" style="display: inline-block; padding: 12px 24px; background: #000; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 500;">Open Multica</a>
</p>
<p style="color: #666; font-size: 14px;">Log in to accept or decline the invitation.</p>
</div>`, workspaceName, inviterName, workspaceName, appURL),
}
_, err := s.client.Emails.Send(params)
return err
}

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS workspace_invitation;

View File

@@ -0,0 +1,20 @@
CREATE TABLE workspace_invitation (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
inviter_id UUID NOT NULL REFERENCES "user"(id),
invitee_email TEXT NOT NULL,
invitee_user_id UUID REFERENCES "user"(id),
role TEXT NOT NULL CHECK (role IN ('admin', 'member')),
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'declined', 'expired')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + INTERVAL '7 days'
);
-- Only one pending invitation per workspace + email at a time.
CREATE UNIQUE INDEX idx_invitation_unique_pending
ON workspace_invitation(workspace_id, invitee_email) WHERE status = 'pending';
-- Fast lookup of pending invitations for a user (by email or user_id).
CREATE INDEX idx_invitation_invitee_email ON workspace_invitation(invitee_email) WHERE status = 'pending';
CREATE INDEX idx_invitation_invitee_user ON workspace_invitation(invitee_user_id) WHERE status = 'pending';

View File

@@ -0,0 +1,288 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: invitation.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const acceptInvitation = `-- name: AcceptInvitation :one
UPDATE workspace_invitation
SET status = 'accepted', updated_at = now()
WHERE id = $1 AND status = 'pending'
RETURNING id, workspace_id, inviter_id, invitee_email, invitee_user_id, role, status, created_at, updated_at, expires_at
`
func (q *Queries) AcceptInvitation(ctx context.Context, id pgtype.UUID) (WorkspaceInvitation, error) {
row := q.db.QueryRow(ctx, acceptInvitation, id)
var i WorkspaceInvitation
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.InviterID,
&i.InviteeEmail,
&i.InviteeUserID,
&i.Role,
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.ExpiresAt,
)
return i, err
}
const createInvitation = `-- name: CreateInvitation :one
INSERT INTO workspace_invitation (workspace_id, inviter_id, invitee_email, invitee_user_id, role)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, workspace_id, inviter_id, invitee_email, invitee_user_id, role, status, created_at, updated_at, expires_at
`
type CreateInvitationParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
InviterID pgtype.UUID `json:"inviter_id"`
InviteeEmail string `json:"invitee_email"`
InviteeUserID pgtype.UUID `json:"invitee_user_id"`
Role string `json:"role"`
}
func (q *Queries) CreateInvitation(ctx context.Context, arg CreateInvitationParams) (WorkspaceInvitation, error) {
row := q.db.QueryRow(ctx, createInvitation,
arg.WorkspaceID,
arg.InviterID,
arg.InviteeEmail,
arg.InviteeUserID,
arg.Role,
)
var i WorkspaceInvitation
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.InviterID,
&i.InviteeEmail,
&i.InviteeUserID,
&i.Role,
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.ExpiresAt,
)
return i, err
}
const declineInvitation = `-- name: DeclineInvitation :one
UPDATE workspace_invitation
SET status = 'declined', updated_at = now()
WHERE id = $1 AND status = 'pending'
RETURNING id, workspace_id, inviter_id, invitee_email, invitee_user_id, role, status, created_at, updated_at, expires_at
`
func (q *Queries) DeclineInvitation(ctx context.Context, id pgtype.UUID) (WorkspaceInvitation, error) {
row := q.db.QueryRow(ctx, declineInvitation, id)
var i WorkspaceInvitation
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.InviterID,
&i.InviteeEmail,
&i.InviteeUserID,
&i.Role,
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.ExpiresAt,
)
return i, err
}
const getInvitation = `-- name: GetInvitation :one
SELECT id, workspace_id, inviter_id, invitee_email, invitee_user_id, role, status, created_at, updated_at, expires_at FROM workspace_invitation
WHERE id = $1
`
func (q *Queries) GetInvitation(ctx context.Context, id pgtype.UUID) (WorkspaceInvitation, error) {
row := q.db.QueryRow(ctx, getInvitation, id)
var i WorkspaceInvitation
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.InviterID,
&i.InviteeEmail,
&i.InviteeUserID,
&i.Role,
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.ExpiresAt,
)
return i, err
}
const getPendingInvitationByEmail = `-- name: GetPendingInvitationByEmail :one
SELECT id, workspace_id, inviter_id, invitee_email, invitee_user_id, role, status, created_at, updated_at, expires_at FROM workspace_invitation
WHERE workspace_id = $1 AND invitee_email = $2 AND status = 'pending'
`
type GetPendingInvitationByEmailParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
InviteeEmail string `json:"invitee_email"`
}
func (q *Queries) GetPendingInvitationByEmail(ctx context.Context, arg GetPendingInvitationByEmailParams) (WorkspaceInvitation, error) {
row := q.db.QueryRow(ctx, getPendingInvitationByEmail, arg.WorkspaceID, arg.InviteeEmail)
var i WorkspaceInvitation
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.InviterID,
&i.InviteeEmail,
&i.InviteeUserID,
&i.Role,
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.ExpiresAt,
)
return i, err
}
const listPendingInvitationsByWorkspace = `-- name: ListPendingInvitationsByWorkspace :many
SELECT wi.id, wi.workspace_id, wi.inviter_id, wi.invitee_email, wi.invitee_user_id, wi.role, wi.status, wi.created_at, wi.updated_at, wi.expires_at,
u.name AS inviter_name,
u.email AS inviter_email
FROM workspace_invitation wi
JOIN "user" u ON u.id = wi.inviter_id
WHERE wi.workspace_id = $1 AND wi.status = 'pending' AND wi.expires_at > now()
ORDER BY wi.created_at DESC
`
type ListPendingInvitationsByWorkspaceRow struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
InviterID pgtype.UUID `json:"inviter_id"`
InviteeEmail string `json:"invitee_email"`
InviteeUserID pgtype.UUID `json:"invitee_user_id"`
Role string `json:"role"`
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
InviterName string `json:"inviter_name"`
InviterEmail string `json:"inviter_email"`
}
func (q *Queries) ListPendingInvitationsByWorkspace(ctx context.Context, workspaceID pgtype.UUID) ([]ListPendingInvitationsByWorkspaceRow, error) {
rows, err := q.db.Query(ctx, listPendingInvitationsByWorkspace, workspaceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListPendingInvitationsByWorkspaceRow{}
for rows.Next() {
var i ListPendingInvitationsByWorkspaceRow
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.InviterID,
&i.InviteeEmail,
&i.InviteeUserID,
&i.Role,
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.ExpiresAt,
&i.InviterName,
&i.InviterEmail,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listPendingInvitationsForUser = `-- name: ListPendingInvitationsForUser :many
SELECT wi.id, wi.workspace_id, wi.inviter_id, wi.invitee_email, wi.invitee_user_id, wi.role, wi.status, wi.created_at, wi.updated_at, wi.expires_at,
w.name AS workspace_name,
u.name AS inviter_name,
u.email AS inviter_email
FROM workspace_invitation wi
JOIN workspace w ON w.id = wi.workspace_id
JOIN "user" u ON u.id = wi.inviter_id
WHERE wi.status = 'pending'
AND (wi.invitee_user_id = $1 OR wi.invitee_email = $2)
AND wi.expires_at > now()
ORDER BY wi.created_at DESC
`
type ListPendingInvitationsForUserParams struct {
InviteeUserID pgtype.UUID `json:"invitee_user_id"`
InviteeEmail string `json:"invitee_email"`
}
type ListPendingInvitationsForUserRow struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
InviterID pgtype.UUID `json:"inviter_id"`
InviteeEmail string `json:"invitee_email"`
InviteeUserID pgtype.UUID `json:"invitee_user_id"`
Role string `json:"role"`
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
WorkspaceName string `json:"workspace_name"`
InviterName string `json:"inviter_name"`
InviterEmail string `json:"inviter_email"`
}
func (q *Queries) ListPendingInvitationsForUser(ctx context.Context, arg ListPendingInvitationsForUserParams) ([]ListPendingInvitationsForUserRow, error) {
rows, err := q.db.Query(ctx, listPendingInvitationsForUser, arg.InviteeUserID, arg.InviteeEmail)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListPendingInvitationsForUserRow{}
for rows.Next() {
var i ListPendingInvitationsForUserRow
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.InviterID,
&i.InviteeEmail,
&i.InviteeUserID,
&i.Role,
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.ExpiresAt,
&i.WorkspaceName,
&i.InviterName,
&i.InviterEmail,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const revokeInvitation = `-- name: RevokeInvitation :exec
DELETE FROM workspace_invitation
WHERE id = $1 AND status = 'pending'
`
func (q *Queries) RevokeInvitation(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, revokeInvitation, id)
return err
}

View File

@@ -374,3 +374,16 @@ type Workspace struct {
IssuePrefix string `json:"issue_prefix"`
IssueCounter int32 `json:"issue_counter"`
}
type WorkspaceInvitation struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
InviterID pgtype.UUID `json:"inviter_id"`
InviteeEmail string `json:"invitee_email"`
InviteeUserID pgtype.UUID `json:"invitee_user_id"`
Role string `json:"role"`
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
}

View File

@@ -0,0 +1,50 @@
-- name: CreateInvitation :one
INSERT INTO workspace_invitation (workspace_id, inviter_id, invitee_email, invitee_user_id, role)
VALUES ($1, $2, $3, $4, $5)
RETURNING *;
-- name: GetInvitation :one
SELECT * FROM workspace_invitation
WHERE id = $1;
-- name: ListPendingInvitationsByWorkspace :many
SELECT wi.*,
u.name AS inviter_name,
u.email AS inviter_email
FROM workspace_invitation wi
JOIN "user" u ON u.id = wi.inviter_id
WHERE wi.workspace_id = $1 AND wi.status = 'pending' AND wi.expires_at > now()
ORDER BY wi.created_at DESC;
-- name: ListPendingInvitationsForUser :many
SELECT wi.*,
w.name AS workspace_name,
u.name AS inviter_name,
u.email AS inviter_email
FROM workspace_invitation wi
JOIN workspace w ON w.id = wi.workspace_id
JOIN "user" u ON u.id = wi.inviter_id
WHERE wi.status = 'pending'
AND (wi.invitee_user_id = $1 OR wi.invitee_email = $2)
AND wi.expires_at > now()
ORDER BY wi.created_at DESC;
-- name: AcceptInvitation :one
UPDATE workspace_invitation
SET status = 'accepted', updated_at = now()
WHERE id = $1 AND status = 'pending'
RETURNING *;
-- name: DeclineInvitation :one
UPDATE workspace_invitation
SET status = 'declined', updated_at = now()
WHERE id = $1 AND status = 'pending'
RETURNING *;
-- name: RevokeInvitation :exec
DELETE FROM workspace_invitation
WHERE id = $1 AND status = 'pending';
-- name: GetPendingInvitationByEmail :one
SELECT * FROM workspace_invitation
WHERE workspace_id = $1 AND invitee_email = $2 AND status = 'pending';

View File

@@ -72,6 +72,12 @@ const (
EventPinCreated = "pin:created"
EventPinDeleted = "pin:deleted"
// Invitation events
EventInvitationCreated = "invitation:created"
EventInvitationAccepted = "invitation:accepted"
EventInvitationDeclined = "invitation:declined"
EventInvitationRevoked = "invitation:revoked"
// Daemon events
EventDaemonHeartbeat = "daemon:heartbeat"
EventDaemonRegister = "daemon:register"