Add options to change share visibility (#108)

Co-authored-by: cte <cestreich@gmail.com>
This commit is contained in:
Matt Rubens 2025-06-19 22:24:43 -04:00 committed by GitHub
parent 008c548c07
commit 1ab782244b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1239 additions and 80 deletions

View file

@ -309,17 +309,24 @@ export const getTasks = async ({
userId,
taskId,
allowCrossUserAccess = false,
skipAuth = false,
}: {
orgId?: string | null;
userId?: string | null;
taskId?: string | null;
allowCrossUserAccess?: boolean;
skipAuth?: boolean;
}): Promise<TaskWithUser[]> => {
const { effectiveUserId } = await authorizeAnalytics({
requestedOrgId: orgId,
requestedUserId: userId,
allowCrossUserAccess,
});
let effectiveUserId = userId;
if (!skipAuth) {
const authResult = await authorizeAnalytics({
requestedOrgId: orgId,
requestedUserId: userId,
allowCrossUserAccess,
});
effectiveUserId = authResult.effectiveUserId;
}
if (!orgId) {
return [];

View file

@ -7,7 +7,8 @@ import {
createTaskShareSchema,
shareIdSchema,
} from '@/types';
import type { SharedByUser } from '@/types';
import type { SharedByUser } from '@/types/task-sharing';
import { TaskShareVisibility } from '@/types/task-sharing';
import { type TaskShare, AuditLogTargetType } from '@/db';
import { client as db, taskShares, users } from '@/db/server';
import { handleError, generateShareToken } from '@/lib/server';
@ -41,6 +42,7 @@ export async function canShareTask(taskId: string): Promise<{
orgRole?: string;
}> {
try {
// Get authentication info
const authResult = await authorize();
if (!authResult.success) {
@ -56,6 +58,7 @@ export async function canShareTask(taskId: string): Promise<{
orgId,
allowCrossUserAccess: true,
});
const task = tasks[0];
if (!task) {
@ -100,7 +103,11 @@ export async function createTaskShare(data: CreateTaskShareRequest) {
return { success: false, error: 'Invalid request data' };
}
const { taskId, expirationDays } = result.data;
const {
taskId,
expirationDays,
visibility = TaskShareVisibility.ORGANIZATION,
} = result.data;
const orgSettingsData = await getOrganizationSettings();
@ -142,6 +149,7 @@ export async function createTaskShare(data: CreateTaskShareRequest) {
orgId,
createdByUserId: userId,
shareToken,
visibility,
expiresAt,
})
.returning();
@ -158,11 +166,12 @@ export async function createTaskShare(data: CreateTaskShareRequest) {
newValue: {
action: 'created',
shareId: insertedShare[0].id,
visibility,
expiresAt: expiresAt.toISOString(),
taskOwnerId: task.userId,
sharedByAdmin: orgRole === 'org:admin' && task.userId !== userId,
},
description: `Created task share for task ${taskId}${
description: `Created ${visibility} task share for task ${taskId}${
orgRole === 'org:admin' && task.userId !== userId
? ` (admin sharing task created by ${task.user.name})`
: ''
@ -182,8 +191,8 @@ export async function createTaskShare(data: CreateTaskShareRequest) {
return {
success: true,
message: 'Task share created successfully',
data: { shareUrl, shareId: newShare.id, expiresAt },
message: 'Task share created successfully',
};
} catch (error) {
return handleError(error, 'task_sharing');
@ -198,18 +207,14 @@ export async function getTaskByShareToken(token: string): Promise<{
messages: Message[];
sharedBy: SharedByUser;
sharedAt: Date;
visibility: string;
} | null> {
try {
const authResult = await authorize();
if (!authResult.success) {
throw new Error('Authentication required');
}
if (!isValidShareToken(token)) {
return null;
}
// First, get the share without auth check to determine visibility
const [shareWithUser] = await db
.select({
share: taskShares,
@ -221,12 +226,7 @@ export async function getTaskByShareToken(token: string): Promise<{
})
.from(taskShares)
.innerJoin(users, eq(taskShares.createdByUserId, users.id))
.where(
and(
eq(taskShares.shareToken, token),
eq(taskShares.orgId, authResult.orgId),
),
)
.where(eq(taskShares.shareToken, token))
.limit(1);
if (!shareWithUser) {
@ -239,12 +239,25 @@ export async function getTaskByShareToken(token: string): Promise<{
return null;
}
// Check visibility and auth requirements
if (share.visibility === TaskShareVisibility.ORGANIZATION) {
const authResult = await authorize();
const userId = authResult.success ? authResult.userId : null;
const orgId = authResult.success ? authResult.orgId : null;
if (!userId || !orgId || orgId !== share.orgId) {
throw new Error('Authentication required for organization shares');
}
}
// For public shares, no auth check needed
// Get task data based on visibility
const tasks = await getTasks({
taskId: share.taskId,
orgId: share.orgId,
allowCrossUserAccess: true,
skipAuth: share.visibility === TaskShareVisibility.PUBLIC, // Skip auth for public shares
});
const task = tasks[0];
if (!task) {
@ -258,6 +271,7 @@ export async function getTaskByShareToken(token: string): Promise<{
messages,
sharedBy: sharedByUser,
sharedAt: share.createdAt,
visibility: share.visibility,
};
} catch (error) {
console.error(
@ -281,6 +295,7 @@ export async function deleteTaskShare(shareId: string) {
}
const { userId, orgId, orgRole } = authResult;
const shareIdResult = shareIdSchema.safeParse(shareId);
if (!shareIdResult.success) {

View file

@ -1,18 +1,22 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { authorizeApi } from '@/actions/auth';
import { createTaskShare } from '@/actions/taskSharing';
import { getTasks } from '@/actions/analytics';
import { getOrganizationSettings } from '@/actions/organizationSettings';
import { authorize } from '@/actions/auth';
import { TaskShareVisibility } from '@/types/task-sharing';
const createShareRequestSchema = z.object({
taskId: z.string().min(1, 'Task ID is required'),
visibility: z
.nativeEnum(TaskShareVisibility)
.default(TaskShareVisibility.ORGANIZATION),
});
export async function POST(request: NextRequest) {
try {
const authResult = await authorizeApi(request);
const authResult = await authorize();
if (!authResult.success) {
return NextResponse.json(
@ -33,7 +37,7 @@ export async function POST(request: NextRequest) {
);
}
const { taskId } = result.data;
const { taskId, visibility } = result.data;
// Check if task sharing is enabled for the organization
const orgSettings = await getOrganizationSettings();
@ -59,7 +63,7 @@ export async function POST(request: NextRequest) {
);
}
const shareResponse = await createTaskShare({ taskId });
const shareResponse = await createTaskShare({ taskId, visibility });
if (!shareResponse.success || !shareResponse.data) {
return NextResponse.json(

View file

@ -1,19 +1,16 @@
import { notFound, redirect } from 'next/navigation';
import { authorize } from '@/actions/auth';
import { getTaskByShareToken } from '@/actions/taskSharing';
import { SharedTaskView } from '@/components/task-sharing/SharedTaskView';
import { Badge } from '@/components/ui';
type SharedTaskPageProps = { params: { token: string } };
type SharedTaskPageProps = {
params: Promise<{
token: string;
}>;
};
export default async function SharedTaskPage({ params }: SharedTaskPageProps) {
const authResult = await authorize();
if (!authResult.success) {
redirect('/select-org');
}
try {
const { token } = await params;
const result = await getTaskByShareToken(token);
@ -22,13 +19,16 @@ export default async function SharedTaskPage({ params }: SharedTaskPageProps) {
notFound();
}
const { task, messages, sharedBy, sharedAt } = result;
const { task, messages, sharedBy, sharedAt, visibility } = result;
return (
<div className="container mx-auto py-6">
<div className="mb-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Shared Task</span>
{visibility === 'public' && (
<Badge variant="secondary">Public</Badge>
)}
</div>
</div>
<SharedTaskView
@ -40,22 +40,15 @@ export default async function SharedTaskPage({ params }: SharedTaskPageProps) {
</div>
);
} catch (error) {
if (error instanceof Error && error.message.includes('Access denied')) {
return (
<div className="container mx-auto py-6">
<div className="text-center">
<h1 className="text-2xl font-bold text-destructive mb-4">
Access Denied
</h1>
<p className="text-muted-foreground mb-4">
You must be a member of the organization to view this shared task.
</p>
<p className="text-sm text-muted-foreground">
Please contact the person who shared this link to ensure you have
the correct organization access.
</p>
</div>
</div>
// Handle auth errors for org shares
if (
error instanceof Error &&
error.message.includes('Authentication required')
) {
// For organization shares that require auth, redirect to sign-in
const { token } = await params;
redirect(
`/sign-in?redirect_url=${encodeURIComponent(`/share/${token}`)}`,
);
}
@ -74,12 +67,14 @@ export async function generateMetadata({ params }: SharedTaskPageProps) {
};
}
const { task } = result;
const { task, visibility } = result;
const title = task.title || `Task by ${task.user.name}`;
return {
title: `Shared Task: ${title}`,
description: `View shared task details and conversation history`,
description: `View shared task details and conversation history${
visibility === 'public' ? ' (Public)' : ''
}`,
};
} catch (_error) {
return {

View file

@ -1,11 +1,20 @@
'use client';
import { useState } from 'react';
import { Copy, Share, Trash2, ExternalLink } from 'lucide-react';
import {
Copy,
Share,
Trash2,
ExternalLink,
Users,
Globe,
Check,
} from 'lucide-react';
import { toast } from 'sonner';
import type { TaskShare } from '@/db';
import type { TaskWithUser } from '@/actions/analytics';
import { TaskShareVisibility } from '@/types/task-sharing';
import {
createTaskShare,
deleteTaskShare,
@ -25,6 +34,8 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
Label,
Badge,
} from '@/components/ui';
type ShareButtonProps = {
@ -36,6 +47,9 @@ export const ShareButton = ({ task }: ShareButtonProps) => {
const [isCreating, setIsCreating] = useState(false);
const [shares, setShares] = useState<TaskShare[]>([]);
const [newShareUrl, setNewShareUrl] = useState<string | null>(null);
const [visibility, setVisibility] = useState<TaskShareVisibility>(
TaskShareVisibility.ORGANIZATION,
);
const { data: orgSettings } = useOrganizationSettings();
@ -58,13 +72,17 @@ export const ShareButton = ({ task }: ShareButtonProps) => {
if (open) {
loadShares();
setNewShareUrl(null);
setVisibility(TaskShareVisibility.ORGANIZATION); // Reset to default when opening
}
};
const handleCreateShare = async () => {
setIsCreating(true);
try {
const response = await createTaskShare({ taskId: task.taskId });
const response = await createTaskShare({
taskId: task.taskId,
visibility,
});
if (response.success && response.data) {
setNewShareUrl(response.data.shareUrl);
@ -119,20 +137,31 @@ export const ShareButton = ({ task }: ShareButtonProps) => {
Share
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Share Task</DialogTitle>
<DialogDescription>
Create a link to share this task with your team.
{newShareUrl
? 'Your share link is ready to use.'
: visibility === TaskShareVisibility.ORGANIZATION
? 'Create a link to share this task with your team.'
: 'Create a public link that anyone can access.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Create New Share */}
{newShareUrl ? (
<div className="space-y-3">
<div className="p-3 border rounded-md">
<p className="text-sm font-medium mb-2">Share link created!</p>
<div className="space-y-4">
<div className="p-4 border-2 border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/30 rounded-lg">
<div className="flex items-center gap-2 mb-3">
<div className="flex-shrink-0">
<Check className="size-5 text-green-600 dark:text-green-400" />
</div>
<p className="font-medium text-green-800 dark:text-green-200">
Share link created!
</p>
</div>
<div className="flex gap-2">
<Button
onClick={(e) => {
@ -156,21 +185,117 @@ export const ShareButton = ({ task }: ShareButtonProps) => {
</Button>
</div>
</div>
<Button
variant="outline"
onClick={() => setNewShareUrl(null)}
className="w-full"
>
Create Another Link
</Button>
</div>
) : (
<Button
onClick={handleCreateShare}
disabled={isCreating}
className="w-full"
>
{isCreating ? 'Creating...' : 'Create Share Link'}
</Button>
<>
{/* Visibility Selector */}
<div className="space-y-3">
<Label className="text-sm font-medium text-foreground">
Who can access this link?
</Label>
<div className="grid gap-3">
<button
type="button"
onClick={() =>
setVisibility(TaskShareVisibility.ORGANIZATION)
}
className={`relative flex items-start gap-3 p-4 rounded-lg border-2 transition-all hover:bg-muted/50 ${
visibility === TaskShareVisibility.ORGANIZATION
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
: 'border-border'
}`}
>
<div
className={`flex-shrink-0 mt-0.5 ${
visibility === TaskShareVisibility.ORGANIZATION
? 'text-primary'
: 'text-muted-foreground'
}`}
>
<Users className="size-5" />
</div>
<div className="flex-1 text-left">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">
Organization
</span>
{visibility === TaskShareVisibility.ORGANIZATION && (
<Check className="size-4 text-primary" />
)}
</div>
<p className="text-sm text-muted-foreground mt-1">
Only members of your organization can view
</p>
</div>
</button>
<button
type="button"
onClick={() => setVisibility(TaskShareVisibility.PUBLIC)}
className={`relative flex items-start gap-3 p-4 rounded-lg border-2 transition-all hover:bg-muted/50 ${
visibility === TaskShareVisibility.PUBLIC
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
: 'border-border'
}`}
>
<div
className={`flex-shrink-0 mt-0.5 ${
visibility === TaskShareVisibility.PUBLIC
? 'text-primary'
: 'text-muted-foreground'
}`}
>
<Globe className="size-5" />
</div>
<div className="flex-1 text-left">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">
Public
</span>
{visibility === TaskShareVisibility.PUBLIC && (
<Check className="size-4 text-primary" />
)}
</div>
<p className="text-sm text-muted-foreground mt-1">
Anyone with the link can view
</p>
</div>
</button>
</div>
</div>
<Button
onClick={handleCreateShare}
disabled={isCreating}
className="w-full h-11 text-base font-medium"
size="lg"
>
{isCreating ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-2 border-current border-t-transparent mr-2" />
Creating...
</>
) : (
<>
<Share className="size-4 mr-2" />
Create Share Link
</>
)}
</Button>
</>
)}
{/* Existing Shares */}
{shares.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-2">
<div className="space-y-3">
<h4 className="text-sm font-medium text-foreground">
Previous Links ({shares.length})
</h4>
<div className="space-y-2">
@ -179,19 +304,34 @@ export const ShareButton = ({ task }: ShareButtonProps) => {
return (
<div
key={share.id}
className="flex items-center justify-between p-2 border rounded"
className="flex items-center gap-3 p-3 border rounded-lg hover:bg-muted/30 transition-colors"
>
<div className="flex-shrink-0">
{share.visibility === TaskShareVisibility.PUBLIC ? (
<Globe className="size-4 text-muted-foreground" />
) : (
<Users className="size-4 text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-muted-foreground truncate">
Created{' '}
{new Date(share.createdAt).toLocaleDateString()}
</p>
<div className="flex items-center gap-2">
<p className="text-xs text-muted-foreground truncate">
Created{' '}
{new Date(share.createdAt).toLocaleDateString()}
</p>
{share.visibility === TaskShareVisibility.PUBLIC && (
<Badge variant="secondary" className="text-xs">
Public
</Badge>
)}
</div>
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyLink(shareUrl)}
className="h-8 w-8 p-0"
>
<Copy className="size-3" />
</Button>
@ -199,6 +339,7 @@ export const ShareButton = ({ task }: ShareButtonProps) => {
variant="ghost"
size="sm"
onClick={() => handleDeleteShare(share.id)}
className="h-8 w-8 p-0"
>
<Trash2 className="size-3" />
</Button>
@ -207,7 +348,7 @@ export const ShareButton = ({ task }: ShareButtonProps) => {
);
})}
{shares.length > 3 && (
<p className="text-xs text-muted-foreground text-center">
<p className="text-xs text-muted-foreground text-center py-2">
+{shares.length - 3} more links
</p>
)}
@ -216,10 +357,14 @@ export const ShareButton = ({ task }: ShareButtonProps) => {
)}
{/* Simple Info */}
<p className="text-xs text-muted-foreground">
Links expire in {expirationDays} days and are only accessible to
your organization members.
</p>
{!newShareUrl && (
<p className="text-xs text-muted-foreground">
Links expire in {expirationDays} days
{visibility === TaskShareVisibility.ORGANIZATION
? ' and are only accessible to your organization members.'
: '. Anyone with the link will be able to view this task.'}
</p>
)}
</div>
</DialogContent>
</Dialog>

View file

@ -0,0 +1,2 @@
ALTER TABLE "task_shares" ADD COLUMN "visibility" text DEFAULT 'organization' NOT NULL;--> statement-breakpoint
CREATE INDEX "task_shares_visibility_idx" ON "task_shares" USING btree ("visibility");

View file

@ -0,0 +1,973 @@
{
"id": "a6128d1e-dc00-490a-8c2c-70da368301bd",
"prevId": "d720a01c-3f4b-4000-a6fd-c5abfc6816b7",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.agent_request_logs": {
"name": "agent_request_logs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"organization_id": {
"name": "organization_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"endpoint": {
"name": "endpoint",
"type": "text",
"primaryKey": false,
"notNull": true
},
"method": {
"name": "method",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status_code": {
"name": "status_code",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"response_time_ms": {
"name": "response_time_ms",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"agent_request_logs_agent_id_idx": {
"name": "agent_request_logs_agent_id_idx",
"columns": [
{
"expression": "agent_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"agent_request_logs_org_id_idx": {
"name": "agent_request_logs_org_id_idx",
"columns": [
{
"expression": "organization_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"agent_request_logs_created_at_idx": {
"name": "agent_request_logs_created_at_idx",
"columns": [
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"agent_request_logs_agent_id_agents_id_fk": {
"name": "agent_request_logs_agent_id_agents_id_fk",
"tableFrom": "agent_request_logs",
"tableTo": "agents",
"columnsFrom": ["agent_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"agent_request_logs_organization_id_organizations_id_fk": {
"name": "agent_request_logs_organization_id_organizations_id_fk",
"tableFrom": "agent_request_logs",
"tableTo": "organizations",
"columnsFrom": ["organization_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.agents": {
"name": "agents",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"organization_id": {
"name": "organization_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
},
"last_used_at": {
"name": "last_used_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"total_requests": {
"name": "total_requests",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_by_user_id": {
"name": "created_by_user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"agents_org_id_idx": {
"name": "agents_org_id_idx",
"columns": [
{
"expression": "organization_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"agents_active_idx": {
"name": "agents_active_idx",
"columns": [
{
"expression": "is_active",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"agents\".\"is_active\" = 1",
"concurrently": false,
"method": "btree",
"with": {}
},
"agents_last_used_idx": {
"name": "agents_last_used_idx",
"columns": [
{
"expression": "last_used_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"agents_organization_id_organizations_id_fk": {
"name": "agents_organization_id_organizations_id_fk",
"tableFrom": "agents",
"tableTo": "organizations",
"columnsFrom": ["organization_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"agents_created_by_user_id_users_id_fk": {
"name": "agents_created_by_user_id_users_id_fk",
"tableFrom": "agents",
"tableTo": "users",
"columnsFrom": ["created_by_user_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.audit_logs": {
"name": "audit_logs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"organization_id": {
"name": "organization_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"target_type": {
"name": "target_type",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"target_id": {
"name": "target_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"new_value": {
"name": "new_value",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"audit_logs_user_id_idx": {
"name": "audit_logs_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"audit_logs_organization_id_idx": {
"name": "audit_logs_organization_id_idx",
"columns": [
{
"expression": "organization_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"audit_logs_target_idx": {
"name": "audit_logs_target_idx",
"columns": [
{
"expression": "target_type",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "target_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"audit_logs_created_at_idx": {
"name": "audit_logs_created_at_idx",
"columns": [
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"audit_logs_user_id_users_id_fk": {
"name": "audit_logs_user_id_users_id_fk",
"tableFrom": "audit_logs",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"audit_logs_organization_id_organizations_id_fk": {
"name": "audit_logs_organization_id_organizations_id_fk",
"tableFrom": "audit_logs",
"tableTo": "organizations",
"columnsFrom": ["organization_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.organization_settings": {
"name": "organization_settings",
"schema": "",
"columns": {
"organization_id": {
"name": "organization_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
},
"cloud_settings": {
"name": "cloud_settings",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"default_settings": {
"name": "default_settings",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"allow_list": {
"name": "allow_list",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{\"allowAll\":true,\"providers\":{}}'::jsonb"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"organization_settings_created_at_idx": {
"name": "organization_settings_created_at_idx",
"columns": [
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"organization_settings_organization_id_organizations_id_fk": {
"name": "organization_settings_organization_id_organizations_id_fk",
"tableFrom": "organization_settings",
"tableTo": "organizations",
"columnsFrom": ["organization_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.organizations": {
"name": "organizations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"entity": {
"name": "entity",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"last_sync_at": {
"name": "last_sync_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"organizations_slug_idx": {
"name": "organizations_slug_idx",
"columns": [
{
"expression": "slug",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"organizations_created_at_idx": {
"name": "organizations_created_at_idx",
"columns": [
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.task_shares": {
"name": "task_shares",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"organization_id": {
"name": "organization_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_by_user_id": {
"name": "created_by_user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"share_token": {
"name": "share_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"visibility": {
"name": "visibility",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'organization'"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"task_shares_share_token_idx": {
"name": "task_shares_share_token_idx",
"columns": [
{
"expression": "share_token",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"task_shares_task_id_idx": {
"name": "task_shares_task_id_idx",
"columns": [
{
"expression": "task_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"task_shares_org_id_idx": {
"name": "task_shares_org_id_idx",
"columns": [
{
"expression": "organization_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"task_shares_expires_at_idx": {
"name": "task_shares_expires_at_idx",
"columns": [
{
"expression": "expires_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"task_shares_created_by_user_id_idx": {
"name": "task_shares_created_by_user_id_idx",
"columns": [
{
"expression": "created_by_user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"task_shares_visibility_idx": {
"name": "task_shares_visibility_idx",
"columns": [
{
"expression": "visibility",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"task_shares_organization_id_organizations_id_fk": {
"name": "task_shares_organization_id_organizations_id_fk",
"tableFrom": "task_shares",
"tableTo": "organizations",
"columnsFrom": ["organization_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"task_shares_created_by_user_id_users_id_fk": {
"name": "task_shares_created_by_user_id_users_id_fk",
"tableFrom": "task_shares",
"tableTo": "users",
"columnsFrom": ["created_by_user_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"task_shares_share_token_unique": {
"name": "task_shares_share_token_unique",
"nullsNotDistinct": false,
"columns": ["share_token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"organization_id": {
"name": "organization_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"organization_role": {
"name": "organization_role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"entity": {
"name": "entity",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"last_sync_at": {
"name": "last_sync_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"users_organization_id_idx": {
"name": "users_organization_id_idx",
"columns": [
{
"expression": "organization_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"users_organization_role_idx": {
"name": "users_organization_role_idx",
"columns": [
{
"expression": "organization_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "organization_role",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"users_email_idx": {
"name": "users_email_idx",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"users_created_at_idx": {
"name": "users_created_at_idx",
"columns": [
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"users_organization_id_organizations_id_fk": {
"name": "users_organization_id_organizations_id_fk",
"tableFrom": "users",
"tableTo": "organizations",
"columnsFrom": ["organization_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -50,6 +50,13 @@
"when": 1750289379751,
"tag": "0006_ambiguous_professor_monster",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1750363919215,
"tag": "0007_tidy_blue_blade",
"breakpoints": true
}
]
}

View file

@ -178,6 +178,7 @@ export const taskShares = pgTable(
.notNull()
.references(() => users.id),
shareToken: text('share_token').notNull().unique(),
visibility: text('visibility').notNull().default('organization'),
expiresAt: timestamp('expires_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
@ -188,6 +189,7 @@ export const taskShares = pgTable(
index('task_shares_org_id_idx').on(table.orgId),
index('task_shares_expires_at_idx').on(table.expiresAt),
index('task_shares_created_by_user_id_idx').on(table.createdByUserId),
index('task_shares_visibility_idx').on(table.visibility),
],
);

View file

@ -6,6 +6,7 @@ const isUnprotectedRoute = createRouteMatcher([
'/sign-up(.*)',
'/extension/sign-in(.*)',
'/api/marketplace(.*)',
'/share(.*)',
'/',
]);

View file

@ -1,8 +1,16 @@
import { z } from 'zod';
export enum TaskShareVisibility {
ORGANIZATION = 'organization',
PUBLIC = 'public',
}
export const createTaskShareSchema = z.object({
taskId: z.string().min(1, 'Task ID is required'),
expirationDays: z.number().int().positive().max(365).optional(),
visibility: z
.nativeEnum(TaskShareVisibility)
.default(TaskShareVisibility.ORGANIZATION),
});
export type CreateTaskShareRequest = z.infer<typeof createTaskShareSchema>;