diff --git a/apps/api/src/app/api/github/install/route.ts b/apps/api/src/app/api/github/install/route.ts index 7dd5d45ee54..2ad4af899cb 100644 --- a/apps/api/src/app/api/github/install/route.ts +++ b/apps/api/src/app/api/github/install/route.ts @@ -1,7 +1,5 @@ import { auth } from "@superset/auth/server"; -import { db } from "@superset/db/client"; -import { members } from "@superset/db/schema"; -import { and, eq } from "drizzle-orm"; +import { findOrgMembership } from "@superset/db/utils"; import { env } from "@/env"; import { createSignedState } from "@/lib/oauth-state"; @@ -23,11 +21,9 @@ export async function GET(request: Request) { ); } - const membership = await db.query.members.findFirst({ - where: and( - eq(members.organizationId, organizationId), - eq(members.userId, session.user.id), - ), + const membership = await findOrgMembership({ + userId: session.user.id, + organizationId, }); if (!membership) { diff --git a/apps/api/src/app/api/integrations/linear/connect/route.ts b/apps/api/src/app/api/integrations/linear/connect/route.ts index a8a59830ad4..3e2e43db0e2 100644 --- a/apps/api/src/app/api/integrations/linear/connect/route.ts +++ b/apps/api/src/app/api/integrations/linear/connect/route.ts @@ -1,7 +1,5 @@ import { auth } from "@superset/auth/server"; -import { db } from "@superset/db/client"; -import { members } from "@superset/db/schema"; -import { and, eq } from "drizzle-orm"; +import { findOrgMembership } from "@superset/db/utils"; import { env } from "@/env"; import { createSignedState } from "@/lib/oauth-state"; @@ -25,11 +23,9 @@ export async function GET(request: Request) { ); } - const membership = await db.query.members.findFirst({ - where: and( - eq(members.organizationId, organizationId), - eq(members.userId, session.user.id), - ), + const membership = await findOrgMembership({ + userId: session.user.id, + organizationId, }); if (!membership) { diff --git a/apps/api/src/app/api/integrations/slack/connect/route.ts b/apps/api/src/app/api/integrations/slack/connect/route.ts index badee4495b6..c4ea30c3122 100644 --- a/apps/api/src/app/api/integrations/slack/connect/route.ts +++ b/apps/api/src/app/api/integrations/slack/connect/route.ts @@ -1,7 +1,5 @@ import { auth } from "@superset/auth/server"; -import { db } from "@superset/db/client"; -import { members } from "@superset/db/schema"; -import { and, eq } from "drizzle-orm"; +import { findOrgMembership } from "@superset/db/utils"; import { env } from "@/env"; import { createSignedState } from "@/lib/oauth-state"; @@ -42,12 +40,7 @@ export async function GET(request: Request) { const userId = session.user.id; - const membership = await db.query.members.findFirst({ - where: and( - eq(members.organizationId, organizationId), - eq(members.userId, userId), - ), - }); + const membership = await findOrgMembership({ userId, organizationId }); if (!membership) { return Response.json( diff --git a/apps/api/src/app/api/integrations/slack/link/route.ts b/apps/api/src/app/api/integrations/slack/link/route.ts index 23dc6da3073..d9daf905875 100644 --- a/apps/api/src/app/api/integrations/slack/link/route.ts +++ b/apps/api/src/app/api/integrations/slack/link/route.ts @@ -1,11 +1,8 @@ import { createHmac } from "node:crypto"; import { auth } from "@superset/auth/server"; import { db } from "@superset/db/client"; -import { - integrationConnections, - members, - usersSlackUsers, -} from "@superset/db/schema"; +import { integrationConnections, usersSlackUsers } from "@superset/db/schema"; +import { findOrgMembership } from "@superset/db/utils"; import { and, eq } from "drizzle-orm"; import { headers } from "next/headers"; import { env } from "@/env"; @@ -65,11 +62,9 @@ export async function GET(request: Request) { ); } - const membership = await db.query.members.findFirst({ - where: and( - eq(members.organizationId, connection.organizationId), - eq(members.userId, session.user.id), - ), + const membership = await findOrgMembership({ + userId: session.user.id, + organizationId: connection.organizationId, }); if (!membership) { diff --git a/apps/marketing/src/lib/blog-utils.ts b/apps/marketing/src/lib/blog-utils.ts index cfa4f014a23..cd3e9270953 100644 --- a/apps/marketing/src/lib/blog-utils.ts +++ b/apps/marketing/src/lib/blog-utils.ts @@ -4,6 +4,7 @@ */ import type { BlogCategory } from "./blog-constants"; +import { formatContentDate } from "./content-utils"; import type { Person } from "./people"; export interface TocItem { @@ -25,17 +26,8 @@ export interface BlogPost { content: string; } -export function formatBlogDate(date: string): string { - return new Date(date).toLocaleDateString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - }); -} +export { slugify } from "./content-utils"; -export function slugify(text: string): string { - return text - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/(^-|-$)/g, ""); +export function formatBlogDate(date: string): string { + return formatContentDate(date, "short"); } diff --git a/apps/marketing/src/lib/blog.ts b/apps/marketing/src/lib/blog.ts index 4931233613b..146a4e772c9 100644 --- a/apps/marketing/src/lib/blog.ts +++ b/apps/marketing/src/lib/blog.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import matter from "gray-matter"; import { type BlogPost, slugify, type TocItem } from "./blog-utils"; +import { normalizeContentDate } from "./content-utils"; import { getPersonById } from "./people"; export { BLOG_CATEGORIES, type BlogCategory } from "./blog-constants"; @@ -21,15 +22,7 @@ function parseFrontmatter(filePath: string): BlogPost | null { const { data, content } = matter(fileContent); const slug = path.basename(filePath, ".mdx"); - - let dateValue: string; - if (data.date instanceof Date) { - dateValue = data.date.toISOString().split("T")[0] as string; - } else if (data.date) { - dateValue = String(data.date); - } else { - dateValue = new Date().toISOString().split("T")[0] as string; - } + const dateValue = normalizeContentDate(data.date) as string; const authorId: string = data.author ?? "unknown"; const author = getPersonById(authorId) ?? { diff --git a/apps/marketing/src/lib/changelog-utils.ts b/apps/marketing/src/lib/changelog-utils.ts index 4a33ff33517..d7337fd5fb8 100644 --- a/apps/marketing/src/lib/changelog-utils.ts +++ b/apps/marketing/src/lib/changelog-utils.ts @@ -3,6 +3,8 @@ * These can be safely imported in both server and client components. */ +import { formatContentDate } from "./content-utils"; + export interface ChangelogEntry { slug: string; url: string; @@ -13,17 +15,8 @@ export interface ChangelogEntry { content: string; } -export function formatChangelogDate(date: string): string { - return new Date(date).toLocaleDateString("en-US", { - year: "numeric", - month: "long", - day: "numeric", - }); -} +export { slugify } from "./content-utils"; -export function slugify(text: string): string { - return text - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/(^-|-$)/g, ""); +export function formatChangelogDate(date: string): string { + return formatContentDate(date, "long"); } diff --git a/apps/marketing/src/lib/changelog.ts b/apps/marketing/src/lib/changelog.ts index dc724a51a43..936c949cfd7 100644 --- a/apps/marketing/src/lib/changelog.ts +++ b/apps/marketing/src/lib/changelog.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import matter from "gray-matter"; import { type ChangelogEntry, slugify } from "./changelog-utils"; +import { normalizeContentDate } from "./content-utils"; export { type ChangelogEntry, @@ -17,15 +18,7 @@ function parseFrontmatter(filePath: string): ChangelogEntry | null { const { data, content } = matter(fileContent); const slug = path.basename(filePath, ".mdx"); - - let dateValue: string; - if (data.date instanceof Date) { - dateValue = data.date.toISOString().split("T")[0] as string; - } else if (data.date) { - dateValue = String(data.date); - } else { - dateValue = new Date().toISOString().split("T")[0] as string; - } + const dateValue = normalizeContentDate(data.date) as string; return { slug, diff --git a/apps/marketing/src/lib/compare-utils.ts b/apps/marketing/src/lib/compare-utils.ts index 1b0361e8ab4..6e5ee5cafd8 100644 --- a/apps/marketing/src/lib/compare-utils.ts +++ b/apps/marketing/src/lib/compare-utils.ts @@ -3,6 +3,8 @@ * These can be safely imported in both server and client components. */ +import { formatContentDate } from "./content-utils"; + export interface ComparisonPage { slug: string; url: string; @@ -18,9 +20,5 @@ export interface ComparisonPage { } export function formatCompareDate(date: string): string { - return new Date(date).toLocaleDateString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - }); + return formatContentDate(date, "short"); } diff --git a/apps/marketing/src/lib/compare.ts b/apps/marketing/src/lib/compare.ts index 2f98fea39fd..08d02217541 100644 --- a/apps/marketing/src/lib/compare.ts +++ b/apps/marketing/src/lib/compare.ts @@ -3,6 +3,7 @@ import path from "node:path"; import matter from "gray-matter"; import { slugify, type TocItem } from "./blog-utils"; import type { ComparisonPage } from "./compare-utils"; +import { normalizeContentDate } from "./content-utils"; export { type ComparisonPage, formatCompareDate } from "./compare-utils"; @@ -14,22 +15,10 @@ function parseFrontmatter(filePath: string): ComparisonPage | null { const { data, content } = matter(fileContent); const slug = path.basename(filePath, ".mdx"); - - let dateValue: string; - if (data.date instanceof Date) { - dateValue = data.date.toISOString().split("T")[0] as string; - } else if (data.date) { - dateValue = String(data.date); - } else { - dateValue = new Date().toISOString().split("T")[0] as string; - } - - let lastUpdated: string | undefined; - if (data.lastUpdated instanceof Date) { - lastUpdated = data.lastUpdated.toISOString().split("T")[0] as string; - } else if (data.lastUpdated) { - lastUpdated = String(data.lastUpdated); - } + const dateValue = normalizeContentDate(data.date) as string; + const lastUpdated = normalizeContentDate(data.lastUpdated, { + fallbackToNow: false, + }); return { slug, diff --git a/apps/marketing/src/lib/content-utils.ts b/apps/marketing/src/lib/content-utils.ts new file mode 100644 index 00000000000..e8df1f7ddc7 --- /dev/null +++ b/apps/marketing/src/lib/content-utils.ts @@ -0,0 +1,46 @@ +const MONTH_SHORT = "short"; +const _MONTH_LONG = "long"; + +export function formatContentDate( + date: string, + monthStyle: "short" | "long" = MONTH_SHORT, +): string { + return new Date(date).toLocaleDateString("en-US", { + year: "numeric", + month: monthStyle, + day: "numeric", + }); +} + +export function slugify(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, ""); +} + +function toDateInput(dateValue: Date | string | number): string { + return new Date(dateValue).toISOString().split("T")[0] as string; +} + +export function normalizeContentDate( + value: unknown, + options: { fallbackToNow?: boolean } = {}, +): string | undefined { + const { fallbackToNow = true } = options; + const fallback = fallbackToNow ? toDateInput(Date.now()) : undefined; + + if (value instanceof Date) { + return toDateInput(value); + } + + if (typeof value === "string" || typeof value === "number") { + return value ? String(value) : fallback; + } + + if (value) { + return String(value); + } + + return fallback; +} diff --git a/bun.lock b/bun.lock index b4719370e94..caf60e653df 100644 --- a/bun.lock +++ b/bun.lock @@ -666,7 +666,6 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.25.3", "@superset/db": "workspace:*", - "@superset/shared": "workspace:*", "drizzle-orm": "0.45.1", "zod": "^4.3.5", }, diff --git a/packages/db/src/utils/index.ts b/packages/db/src/utils/index.ts index f1a2f5d569e..0d00e4fedd7 100644 --- a/packages/db/src/utils/index.ts +++ b/packages/db/src/utils/index.ts @@ -1 +1,2 @@ +export * from "./membership"; export * from "./sql"; diff --git a/packages/db/src/utils/membership.ts b/packages/db/src/utils/membership.ts new file mode 100644 index 00000000000..4da606613eb --- /dev/null +++ b/packages/db/src/utils/membership.ts @@ -0,0 +1,19 @@ +import { and, eq } from "drizzle-orm"; + +import { db } from "../client"; +import { members } from "../schema"; + +export async function findOrgMembership({ + userId, + organizationId, +}: { + userId: string; + organizationId: string; +}) { + return db.query.members.findFirst({ + where: and( + eq(members.organizationId, organizationId), + eq(members.userId, userId), + ), + }); +} diff --git a/packages/trpc/src/lib/upload.ts b/packages/trpc/src/lib/upload.ts new file mode 100644 index 00000000000..cea7a9b9ecd --- /dev/null +++ b/packages/trpc/src/lib/upload.ts @@ -0,0 +1,62 @@ +import { TRPCError } from "@trpc/server"; +import { del, put } from "@vercel/blob"; + +const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp"]; +const MAX_SIZE_MB = 4.5; + +export async function uploadImage({ + fileData, + mimeType, + pathname, + existingUrl, +}: { + fileData: string; + mimeType: string; + pathname: string; + existingUrl: string | null; +}) { + if (!ALLOWED_IMAGE_TYPES.includes(mimeType)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Invalid image type. Only PNG, JPEG, and WebP are allowed", + }); + } + + const base64Data = fileData.includes("base64,") + ? fileData.split("base64,")[1] || fileData + : fileData; + const buffer = Buffer.from(base64Data, "base64"); + + const sizeInMB = buffer.length / (1024 * 1024); + if (sizeInMB > MAX_SIZE_MB) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `File too large (${sizeInMB.toFixed(2)}MB). Maximum size is ${MAX_SIZE_MB}MB`, + }); + } + + if (existingUrl) { + try { + await del(existingUrl); + } catch {} + } + + const blob = await put(pathname, buffer, { + access: "public", + contentType: mimeType, + }); + + return blob.url; +} + +export function generateImagePathname({ + prefix, + mimeType, +}: { + prefix: string; + mimeType: string; +}) { + const ext = mimeType.split("/")[1]?.replace("jpeg", "jpg") || "png"; + const randomId = Math.random().toString(36).substring(2, 15); + return `${prefix}/${randomId}.${ext}`; +} diff --git a/packages/trpc/src/router/integration/utils.ts b/packages/trpc/src/router/integration/utils.ts index 56e36b3cbed..d4828ab574c 100644 --- a/packages/trpc/src/router/integration/utils.ts +++ b/packages/trpc/src/router/integration/utils.ts @@ -1,18 +1,11 @@ -import { db } from "@superset/db/client"; -import { members } from "@superset/db/schema"; +import { findOrgMembership } from "@superset/db/utils"; import { TRPCError } from "@trpc/server"; -import { and, eq } from "drizzle-orm"; export async function verifyOrgMembership( userId: string, organizationId: string, ) { - const membership = await db.query.members.findFirst({ - where: and( - eq(members.organizationId, organizationId), - eq(members.userId, userId), - ), - }); + const membership = await findOrgMembership({ userId, organizationId }); if (!membership) { throw new TRPCError({ diff --git a/packages/trpc/src/router/organization/organization.ts b/packages/trpc/src/router/organization/organization.ts index fd6729d2ea5..00a7247ac4f 100644 --- a/packages/trpc/src/router/organization/organization.ts +++ b/packages/trpc/src/router/organization/organization.ts @@ -5,11 +5,12 @@ import { sessions as authSessions, invitations, } from "@superset/db/schema/auth"; +import { findOrgMembership } from "@superset/db/utils"; import { canRemoveMember, type OrganizationRole } from "@superset/shared/auth"; import { TRPCError, type TRPCRouterRecord } from "@trpc/server"; -import { del, put } from "@vercel/blob"; import { and, desc, eq, ne } from "drizzle-orm"; import { z } from "zod"; +import { generateImagePathname, uploadImage } from "../../lib/upload"; import { protectedProcedure, publicProcedure } from "../../trpc"; export const organizationRouter = { @@ -146,11 +147,9 @@ export const organizationRouter = { .mutation(async ({ ctx, input }) => { const { id, ...data } = input; - const membership = await db.query.members.findFirst({ - where: and( - eq(members.organizationId, id), - eq(members.userId, ctx.session.user.id), - ), + const membership = await findOrgMembership({ + userId: ctx.session.user.id, + organizationId: id, }); if (!membership) { @@ -215,11 +214,9 @@ export const organizationRouter = { }), ) .mutation(async ({ ctx, input }) => { - const membership = await db.query.members.findFirst({ - where: and( - eq(members.organizationId, input.organizationId), - eq(members.userId, ctx.session.user.id), - ), + const membership = await findOrgMembership({ + userId: ctx.session.user.id, + organizationId: input.organizationId, }); if (!membership) { @@ -247,57 +244,28 @@ export const organizationRouter = { }); } - if (organization.logo) { - try { - await del(organization.logo); - } catch { - // Old logo doesn't exist or isn't in blob storage - that's fine - } - } - - const allowedMimeTypes = ["image/png", "image/jpeg", "image/webp"]; - if (!allowedMimeTypes.includes(input.mimeType)) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Invalid image type. Only PNG, JPEG, and WebP are allowed", - }); - } - - const ext = input.mimeType.split("/")[1]?.replace("jpeg", "jpg") || "png"; - const randomId = Math.random().toString(36).substring(2, 15); - const pathname = `organization/${input.organizationId}/logo/${randomId}.${ext}`; - - const base64Data = input.fileData.includes("base64,") - ? input.fileData.split("base64,")[1] || input.fileData - : input.fileData; - const buffer = Buffer.from(base64Data, "base64"); - - const sizeInMB = buffer.length / (1024 * 1024); - if (sizeInMB > 4.5) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `File too large (${sizeInMB.toFixed(2)}MB). Maximum size is 4.5MB`, - }); - } + const pathname = generateImagePathname({ + prefix: `organization/${input.organizationId}/logo`, + mimeType: input.mimeType, + }); try { - const blob = await put(pathname, buffer, { - access: "public", - contentType: input.mimeType, + const url = await uploadImage({ + fileData: input.fileData, + mimeType: input.mimeType, + pathname, + existingUrl: organization.logo, }); const [updatedOrg] = await db .update(organizations) - .set({ logo: blob.url }) + .set({ logo: url }) .where(eq(organizations.id, input.organizationId)) .returning(); - return { - success: true, - url: blob.url, - organization: updatedOrg, - }; + return { success: true, url, organization: updatedOrg }; } catch (error) { + if (error instanceof TRPCError) throw error; console.error("[organization/uploadLogo] Upload failed:", error); throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", diff --git a/packages/trpc/src/router/user/user.ts b/packages/trpc/src/router/user/user.ts index 5840a707f76..233e04b15e2 100644 --- a/packages/trpc/src/router/user/user.ts +++ b/packages/trpc/src/router/user/user.ts @@ -1,10 +1,10 @@ import { db } from "@superset/db/client"; import { members, users } from "@superset/db/schema"; import { TRPCError, type TRPCRouterRecord } from "@trpc/server"; -import { del, put } from "@vercel/blob"; import { and, desc, eq } from "drizzle-orm"; import { z } from "zod"; +import { generateImagePathname, uploadImage } from "../../lib/upload"; import { protectedProcedure } from "../../trpc"; export const userRouter = { @@ -74,57 +74,28 @@ export const userRouter = { }); } - const allowedMimeTypes = ["image/png", "image/jpeg", "image/webp"]; - if (!allowedMimeTypes.includes(input.mimeType)) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Invalid image type. Only PNG, JPEG, and WebP are allowed", - }); - } - - const base64Data = input.fileData.includes("base64,") - ? input.fileData.split("base64,")[1] || input.fileData - : input.fileData; - const buffer = Buffer.from(base64Data, "base64"); - - const sizeInMB = buffer.length / (1024 * 1024); - if (sizeInMB > 4.5) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `File too large (${sizeInMB.toFixed(2)}MB). Maximum size is 4.5MB`, - }); - } - - if (user.image) { - try { - await del(user.image); - } catch { - // Old avatar doesn't exist or isn't in blob storage - that's fine - } - } - - const ext = input.mimeType.split("/")[1]?.replace("jpeg", "jpg") || "png"; - const randomId = Math.random().toString(36).substring(2, 15); - const pathname = `user/${userId}/avatar/${randomId}.${ext}`; + const pathname = generateImagePathname({ + prefix: `user/${userId}/avatar`, + mimeType: input.mimeType, + }); try { - const blob = await put(pathname, buffer, { - access: "public", - contentType: input.mimeType, + const url = await uploadImage({ + fileData: input.fileData, + mimeType: input.mimeType, + pathname, + existingUrl: user.image, }); const [updatedUser] = await db .update(users) - .set({ image: blob.url }) + .set({ image: url }) .where(eq(users.id, userId)) .returning(); - return { - success: true, - url: blob.url, - user: updatedUser, - }; + return { success: true, url, user: updatedUser }; } catch (error) { + if (error instanceof TRPCError) throw error; console.error("[user/uploadAvatar] Upload failed:", error); throw new TRPCError({ code: "INTERNAL_SERVER_ERROR",