Skip to content

Commit fa894b7

Browse files
Merge branch 'main' into local-upload-tracking
2 parents 35ebc8d + 9054998 commit fa894b7

File tree

13 files changed

+650
-73
lines changed

13 files changed

+650
-73
lines changed

apps/web/actions/video/upload.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ export async function createVideoAndGetUploadUrl({
164164
isScreenshot = false,
165165
isUpload = false,
166166
folderId,
167+
orgId,
167168
supportsUploadProgress = false,
168169
}: {
169170
videoId?: Video.VideoId;
@@ -174,6 +175,7 @@ export async function createVideoAndGetUploadUrl({
174175
isScreenshot?: boolean;
175176
isUpload?: boolean;
176177
folderId?: Folder.FolderId;
178+
orgId: string;
177179
// TODO: Remove this once we are happy with it's stability
178180
supportsUploadProgress?: boolean;
179181
}) {
@@ -228,6 +230,7 @@ export async function createVideoAndGetUploadUrl({
228230
isScreenshot ? "Screenshot" : isUpload ? "Upload" : "Recording"
229231
} - ${formattedDate}`,
230232
ownerId: user.id,
233+
orgId,
231234
source: { type: "desktopMP4" as const },
232235
isScreenshot,
233236
bucket: customBucket?.id,

apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export const UploadCapButton = ({
2727
grey?: boolean;
2828
folderId?: Folder.FolderId;
2929
}) => {
30-
const { user } = useDashboardContext();
30+
const { user, activeOrganization } = useDashboardContext();
3131
const inputRef = useRef<HTMLInputElement>(null);
3232
const { uploadingStore, setUploadStatus } = useUploadingContext();
3333
const isUploading = useStore(uploadingStore, (s) => !!s.uploadStatus);
@@ -52,9 +52,16 @@ export const UploadCapButton = ({
5252
const file = e.target.files?.[0];
5353
if (!file || !user) return;
5454

55+
// This should be unreachable.
56+
if (activeOrganization === null) {
57+
alert("No organization active!");
58+
return;
59+
}
60+
5561
const ok = await legacyUploadCap(
5662
file,
5763
folderId,
64+
activeOrganization.organization.id,
5865
setUploadStatus,
5966
queryClient,
6067
);
@@ -93,6 +100,7 @@ export const UploadCapButton = ({
93100
async function legacyUploadCap(
94101
file: File,
95102
folderId: Folder.FolderId | undefined,
103+
orgId: string,
96104
setUploadStatus: (state: UploadStatus | undefined) => void,
97105
queryClient: QueryClient,
98106
) {
@@ -127,6 +135,7 @@ async function legacyUploadCap(
127135
isScreenshot: false,
128136
isUpload: true,
129137
folderId,
138+
orgId,
130139
});
131140

132141
const uploadId = videoData.id;
@@ -451,6 +460,7 @@ async function legacyUploadCap(
451460
videoId: uploadId,
452461
isScreenshot: true,
453462
isUpload: true,
463+
orgId,
454464
});
455465

456466
const screenshotFormData = new FormData();

apps/web/app/(org)/dashboard/dashboard-data.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export async function getDashboardData(user: typeof userSelectProps) {
4848
email: users.email,
4949
inviteQuota: users.inviteQuota,
5050
image: users.image,
51+
defaultOrgId: users.defaultOrgId,
5152
},
5253
})
5354
.from(organizations)

apps/web/app/(org)/dashboard/settings/account/Settings.tsx

Lines changed: 62 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,47 @@
11
"use client";
22

33
import type { users } from "@cap/database/schema";
4-
import { Button, Card, CardDescription, CardTitle, Input } from "@cap/ui";
4+
import {
5+
Button,
6+
Card,
7+
CardDescription,
8+
CardTitle,
9+
Input,
10+
Select,
11+
} from "@cap/ui";
512
import { useMutation } from "@tanstack/react-query";
613
import { useRouter } from "next/navigation";
7-
import { useState } from "react";
14+
import { useEffect, useState } from "react";
815
import { toast } from "sonner";
16+
import { useDashboardContext } from "../../Contexts";
17+
import { patchAccountSettings } from "./server";
918

1019
export const Settings = ({
1120
user,
1221
}: {
1322
user?: typeof users.$inferSelect | null;
1423
}) => {
24+
const router = useRouter();
25+
const { organizationData } = useDashboardContext();
1526
const [firstName, setFirstName] = useState(user?.name || "");
1627
const [lastName, setLastName] = useState(user?.lastName || "");
17-
const router = useRouter();
28+
const [defaultOrgId, setDefaultOrgId] = useState<string | undefined>(
29+
user?.defaultOrgId || undefined,
30+
);
31+
32+
// Track if form has unsaved changes
33+
const hasChanges =
34+
firstName !== (user?.name || "") ||
35+
lastName !== (user?.lastName || "") ||
36+
defaultOrgId !== user?.defaultOrgId;
1837

1938
const { mutate: updateName, isPending: updateNamePending } = useMutation({
2039
mutationFn: async () => {
21-
const res = await fetch("/api/settings/user/name", {
22-
method: "POST",
23-
headers: { "Content-Type": "application/json" },
24-
body: JSON.stringify({
25-
firstName: firstName.trim(),
26-
lastName: lastName.trim() ? lastName.trim() : null,
27-
}),
28-
});
29-
if (!res.ok) {
30-
throw new Error("Failed to update name");
31-
}
40+
await patchAccountSettings(
41+
firstName.trim(),
42+
lastName.trim() ? lastName.trim() : undefined,
43+
defaultOrgId,
44+
);
3245
},
3346
onSuccess: () => {
3447
toast.success("Name updated successfully");
@@ -39,6 +52,19 @@ export const Settings = ({
3952
},
4053
});
4154

55+
// Prevent navigation when there are unsaved changes
56+
useEffect(() => {
57+
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
58+
if (hasChanges) {
59+
e.preventDefault();
60+
e.returnValue = "";
61+
}
62+
};
63+
64+
window.addEventListener("beforeunload", handleBeforeUnload);
65+
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
66+
}, [hasChanges]);
67+
4268
return (
4369
<form
4470
onSubmit={(e) => {
@@ -91,9 +117,30 @@ export const Settings = ({
91117
disabled
92118
/>
93119
</Card>
120+
<Card className="flex flex-col flex-1 gap-4 justify-between items-stretch">
121+
<div className="space-y-1">
122+
<CardTitle>Default organization</CardTitle>
123+
<CardDescription>This is the default organization</CardDescription>
124+
</div>
125+
126+
<Select
127+
placeholder="Default organization"
128+
value={
129+
defaultOrgId ??
130+
user?.defaultOrgId ??
131+
organizationData?.[0]?.organization.id ??
132+
""
133+
}
134+
onValueChange={(value) => setDefaultOrgId(value)}
135+
options={(organizationData || []).map((org) => ({
136+
value: org.organization.id,
137+
label: org.organization.name,
138+
}))}
139+
/>
140+
</Card>
94141
</div>
95142
<Button
96-
disabled={!firstName || updateNamePending}
143+
disabled={!firstName || updateNamePending || !hasChanges}
97144
className="mt-6"
98145
type="submit"
99146
size="sm"
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"use server";
2+
3+
import { db } from "@cap/database";
4+
import { getCurrentUser } from "@cap/database/auth/session";
5+
import {
6+
organizationMembers,
7+
organizations,
8+
users,
9+
} from "@cap/database/schema";
10+
import { eq, or } from "drizzle-orm";
11+
import { revalidatePath } from "next/cache";
12+
13+
export async function patchAccountSettings(
14+
firstName?: string,
15+
lastName?: string,
16+
defaultOrgId?: string,
17+
) {
18+
const currentUser = await getCurrentUser();
19+
if (!currentUser) throw new Error("Unauthorized");
20+
21+
const updatePayload: Partial<{
22+
name: string;
23+
lastName: string;
24+
defaultOrgId: string;
25+
}> = {};
26+
27+
if (firstName !== undefined) updatePayload.name = firstName;
28+
if (lastName !== undefined) updatePayload.lastName = lastName;
29+
if (defaultOrgId !== undefined) {
30+
const userOrganizations = await db()
31+
.select({
32+
id: organizations.id,
33+
})
34+
.from(organizations)
35+
.leftJoin(
36+
organizationMembers,
37+
eq(organizations.id, organizationMembers.organizationId),
38+
)
39+
.where(
40+
or(
41+
// User owns the organization
42+
eq(organizations.ownerId, currentUser.id),
43+
// User is a member of the organization
44+
eq(organizationMembers.userId, currentUser.id),
45+
),
46+
)
47+
// Remove duplicates if user is both owner and member
48+
.groupBy(organizations.id);
49+
50+
const userOrgIds = userOrganizations.map((org) => org.id);
51+
52+
if (!userOrgIds.includes(defaultOrgId))
53+
throw new Error(
54+
"Forbidden: User does not have access to the specified organization",
55+
);
56+
57+
updatePayload.defaultOrgId = defaultOrgId;
58+
}
59+
60+
await db()
61+
.update(users)
62+
.set(updatePayload)
63+
.where(eq(users.id, currentUser.id));
64+
65+
revalidatePath("/dashboard/settings/account");
66+
}

apps/web/app/api/desktop/[...route]/video.ts

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,20 @@ import { db } from "@cap/database";
22
import { sendEmail } from "@cap/database/emails/config";
33
import { FirstShareableLink } from "@cap/database/emails/first-shareable-link";
44
import { nanoId } from "@cap/database/helpers";
5-
import { s3Buckets, videos, videoUploads } from "@cap/database/schema";
5+
import {
6+
organizationMembers,
7+
organizations,
8+
s3Buckets,
9+
users,
10+
videos,
11+
videoUploads,
12+
} from "@cap/database/schema";
613
import { buildEnv, NODE_ENV, serverEnv } from "@cap/env";
714
import { userIsPro } from "@cap/utils";
815
import { S3Buckets } from "@cap/web-backend";
916
import { Video } from "@cap/web-domain";
1017
import { zValidator } from "@hono/zod-validator";
11-
import { and, count, eq, gt, gte, lt, lte } from "drizzle-orm";
18+
import { and, count, eq, gt, gte, lt, lte, or } from "drizzle-orm";
1219
import { Effect, Option } from "effect";
1320
import { Hono } from "hono";
1421
import { z } from "zod";
@@ -34,6 +41,7 @@ app.get(
3441
width: stringOrNumberOptional,
3542
height: stringOrNumberOptional,
3643
fps: stringOrNumberOptional,
44+
orgId: z.string().optional(),
3745
}),
3846
),
3947
async (c) => {
@@ -47,6 +55,7 @@ app.get(
4755
width,
4856
height,
4957
fps,
58+
orgId,
5059
} = c.req.valid("query");
5160
const user = c.get("user");
5261

@@ -71,8 +80,6 @@ app.get(
7180
.from(s3Buckets)
7281
.where(eq(s3Buckets.ownerId, user.id));
7382

74-
console.log("User bucket:", customBucket ? "found" : "not found");
75-
7683
const date = new Date();
7784
const formattedDate = `${date.getDate()} ${date.toLocaleString(
7885
"default",
@@ -95,6 +102,58 @@ app.get(
95102
});
96103
}
97104

105+
const userOrganizations = await db()
106+
.select({
107+
id: organizations.id,
108+
name: organizations.name,
109+
})
110+
.from(organizations)
111+
.leftJoin(
112+
organizationMembers,
113+
eq(organizations.id, organizationMembers.organizationId),
114+
)
115+
.where(
116+
or(
117+
// User owns the organization
118+
eq(organizations.ownerId, user.id),
119+
// User is a member of the organization
120+
eq(organizationMembers.userId, user.id),
121+
),
122+
)
123+
// Remove duplicates if user is both owner and member
124+
.groupBy(organizations.id, organizations.name)
125+
.orderBy(organizations.createdAt);
126+
const userOrgIds = userOrganizations.map((org) => org.id);
127+
128+
let videoOrgId: string;
129+
if (orgId) {
130+
// Hard error if the user requested org is non-existent or they don't have access.
131+
if (!userOrgIds.includes(orgId))
132+
return c.json({ error: "forbidden_org" }, { status: 403 });
133+
videoOrgId = orgId;
134+
} else if (user.defaultOrgId) {
135+
// User's defaultOrgId is no longer valid, switch to first available org
136+
if (!userOrgIds.includes(user.defaultOrgId)) {
137+
if (!userOrganizations[0])
138+
return c.json({ error: "no_valid_org" }, { status: 403 });
139+
140+
videoOrgId = userOrganizations[0].id;
141+
142+
// Update user's defaultOrgId to the new valid org
143+
await db()
144+
.update(users)
145+
.set({
146+
defaultOrgId: videoOrgId,
147+
})
148+
.where(eq(users.id, user.id));
149+
} else videoOrgId = user.defaultOrgId;
150+
} else {
151+
// No orgId provided and no defaultOrgId, use first available org
152+
if (!userOrganizations[0])
153+
return c.json({ error: "no_valid_org" }, { status: 403 });
154+
videoOrgId = userOrganizations[0].id;
155+
}
156+
98157
const idToUse = Video.VideoId.make(nanoId());
99158

100159
const videoName =
@@ -107,6 +166,7 @@ app.get(
107166
id: idToUse,
108167
name: videoName,
109168
ownerId: user.id,
169+
orgId: videoOrgId,
110170
source:
111171
recordingMode === "hls"
112172
? { type: "local" as const }

apps/web/app/globals.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,7 @@ span,
342342
input,
343343
label,
344344
button {
345-
@apply tracking-normal text-gray-10 font-normal leading-[1.5rem];
345+
@apply tracking-normal font-normal leading-[1.5rem];
346346
}
347347

348348
a {

packages/database/drizzle.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ export default {
1515
dialect: "mysql",
1616
dbCredentials: { url: URL },
1717
casing: "snake_case",
18+
tablesFilter: ["*", "!cluster_*"],
1819
} satisfies Config;

0 commit comments

Comments
 (0)