mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-28 18:09:14 +02:00
Compare commits
3 Commits
agent/lamb
...
agent/j/00
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bbf11fff7 | ||
|
|
67afc1c4bd | ||
|
|
12d05b8c6e |
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
420
server/internal/handler/invitation.go
Normal file
420
server/internal/handler/invitation.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
1
server/migrations/041_workspace_invitation.down.sql
Normal file
1
server/migrations/041_workspace_invitation.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS workspace_invitation;
|
||||
20
server/migrations/041_workspace_invitation.up.sql
Normal file
20
server/migrations/041_workspace_invitation.up.sql
Normal 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';
|
||||
288
server/pkg/db/generated/invitation.sql.go
Normal file
288
server/pkg/db/generated/invitation.sql.go
Normal 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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
50
server/pkg/db/queries/invitation.sql
Normal file
50
server/pkg/db/queries/invitation.sql
Normal 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';
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user