Organize/lib/actions/profile.ts

104 lines
3.1 KiB
TypeScript

"use server";
import { revalidatePath } from "next/cache";
import sharp from "sharp";
import { prisma } from "@/lib/db";
import { requireUserId } from "@/lib/auth-helpers";
import { ProfileNameSchema } from "@/lib/validation/profile";
/**
* Refresh both the Profile page (its form's initial values) and the (app)
* layout (the sidebar shows the user's name/photo) after a change.
* "layout" type invalidates the whole (app) tree -- see
* next/docs revalidatePath "Revalidating a Layout path".
*/
function revalidateProfile() {
revalidatePath("/profile");
revalidatePath("/", "layout");
}
export type ProfileResult = { error?: string };
/**
* Sets the user's first and last name (either may be blank). Blank values
* are stored as null so "cleared" and "never set" mean the same thing.
*/
export async function updateProfileNames(
firstName: string,
lastName: string
): Promise<ProfileResult> {
const userId = await requireUserId();
const parsed = ProfileNameSchema.parse({ firstName, lastName });
await prisma.user.update({
where: { id: userId },
data: {
firstName: parsed.firstName || null,
lastName: parsed.lastName || null,
},
});
revalidateProfile();
return {};
}
const MAX_AVATAR_BYTES = 10 * 1024 * 1024;
const ALLOWED_AVATAR_TYPES = new Set([
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
]);
// Avatars render at most a couple of dozen CSS pixels; 256x256 covers
// retina without bloat. Re-encoded as JPEG so every row stored in the DB
// has the same shape regardless of the upload's original format.
const AVATAR_SIZE = 256;
export type UploadAvatarResult = { avatar?: string; error?: string };
/**
* Stores the user's profile photo. The upload is resized to 256x256
* (cover crop, EXIF-rotated) and re-encoded as JPEG server-side, then
* saved as a data URL on the User row -- the app's container filesystem
* is ephemeral (only the Postgres volume survives deploys), so the image
* lives in the database rather than on disk.
*/
export async function uploadAvatar(file: File): Promise<UploadAvatarResult> {
const userId = await requireUserId();
if (!ALLOWED_AVATAR_TYPES.has(file.type)) {
return { error: "Please choose a JPEG, PNG, WebP, or GIF image." };
}
if (file.size === 0) return { error: "That file is empty." };
if (file.size > MAX_AVATAR_BYTES) {
return { error: "Image must be 10 MB or smaller." };
}
let encoded: Buffer;
try {
encoded = await sharp(Buffer.from(await file.arrayBuffer()))
.rotate() // honor EXIF orientation from phones' photos
.resize(AVATAR_SIZE, AVATAR_SIZE, { fit: "cover" })
.jpeg({ quality: 85 })
.toBuffer();
} catch {
return { error: "That doesn't look like a valid image." };
}
const avatar = `data:image/jpeg;base64,${encoded.toString("base64")}`;
await prisma.user.update({ where: { id: userId }, data: { avatar } });
revalidateProfile();
return { avatar };
}
export async function removeAvatar(): Promise<ProfileResult> {
const userId = await requireUserId();
await prisma.user.update({ where: { id: userId }, data: { avatar: null } });
revalidateProfile();
return {};
}