* feat: add YouTube social link to user profiles - Add youtube_url column to auth_users schema - Add DB migration for the new column - Update updateProfile action with YouTube URL validation - Update getUserProfile and getPublicProfile to include youtubeUrl - Add YouTube input field to settings page - Display YouTube icon on public profile pages * fix: use drizzle-kit generate for youtube_url migration Replaces manually created migration with proper drizzle-kit generated migration that includes meta snapshot and journal entry. --------- Co-authored-by: Anton Pascal <anton-pascal@users.noreply.github.com>
312 lines
8.1 KiB
TypeScript
312 lines
8.1 KiB
TypeScript
'use server'
|
|
|
|
import { revalidatePath } from 'next/cache'
|
|
import { redirect } from 'next/navigation'
|
|
import { db, schema } from '@pascal-app/db'
|
|
import { eq, and, ne, sql } from 'drizzle-orm'
|
|
import { auth } from '@/lib/auth'
|
|
import { getSession } from './server'
|
|
import { createServerSupabaseClient } from '../database/server'
|
|
|
|
/**
|
|
* Sign in with a social provider (Google)
|
|
*/
|
|
export async function signInSocial(provider: 'google', callbackURL?: string) {
|
|
const result = await auth.api.signInSocial({
|
|
body: { provider, callbackURL: callbackURL ?? '/' },
|
|
})
|
|
revalidatePath('/')
|
|
if (result.url && result.redirect) {
|
|
redirect(result.url as '/')
|
|
}
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Update the current user's public username
|
|
*/
|
|
export async function updateUsername(
|
|
username: string,
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
const session = await getSession()
|
|
if (!session?.user) {
|
|
return { success: false, error: 'Not authenticated' }
|
|
}
|
|
|
|
// Validate username format
|
|
const trimmed = username.trim()
|
|
if (trimmed.length < 3) {
|
|
return { success: false, error: 'Username must be at least 3 characters' }
|
|
}
|
|
if (trimmed.length > 30) {
|
|
return { success: false, error: 'Username must be at most 30 characters' }
|
|
}
|
|
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
|
|
return {
|
|
success: false,
|
|
error: 'Username can only contain letters, numbers, hyphens, and underscores',
|
|
}
|
|
}
|
|
|
|
// Check if username is already taken (case-insensitive)
|
|
const existing = await db
|
|
.select({ id: schema.users.id })
|
|
.from(schema.users)
|
|
.where(
|
|
and(
|
|
sql`lower(${schema.users.username}) = lower(${trimmed})`,
|
|
ne(schema.users.id, session.user.id),
|
|
),
|
|
)
|
|
.limit(1)
|
|
|
|
if (existing.length > 0) {
|
|
return { success: false, error: 'Username is already taken' }
|
|
}
|
|
|
|
const updated = await db
|
|
.update(schema.users)
|
|
.set({ username: trimmed })
|
|
.where(eq(schema.users.id, session.user.id))
|
|
.returning({ id: schema.users.id })
|
|
|
|
if (updated.length === 0) {
|
|
return { success: false, error: 'User not found. Please sign out and sign in again.' }
|
|
}
|
|
|
|
revalidatePath('/')
|
|
revalidatePath('/settings')
|
|
return { success: true }
|
|
}
|
|
|
|
/**
|
|
* Get the current user's username
|
|
*/
|
|
export async function getUsername(): Promise<string | null> {
|
|
const session = await getSession()
|
|
if (!session?.user) return null
|
|
|
|
const result = await db
|
|
.select({ username: schema.users.username })
|
|
.from(schema.users)
|
|
.where(eq(schema.users.id, session.user.id))
|
|
.limit(1)
|
|
|
|
return result[0]?.username ?? null
|
|
}
|
|
|
|
/**
|
|
* Check if a username is available
|
|
*/
|
|
export async function checkUsernameAvailability(
|
|
username: string,
|
|
): Promise<{ available: boolean }> {
|
|
const trimmed = username.trim()
|
|
if (trimmed.length < 3 || !/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
|
|
return { available: false }
|
|
}
|
|
|
|
const existing = await db
|
|
.select({ id: schema.users.id })
|
|
.from(schema.users)
|
|
.where(sql`lower(${schema.users.username}) = lower(${trimmed})`)
|
|
.limit(1)
|
|
|
|
return { available: existing.length === 0 }
|
|
}
|
|
|
|
/**
|
|
* Get the current user's full profile
|
|
*/
|
|
export async function getUserProfile(): Promise<{
|
|
username: string | null
|
|
githubUrl: string | null
|
|
xUrl: string | null
|
|
youtubeUrl: string | null
|
|
emailNotifications: boolean
|
|
} | null> {
|
|
const session = await getSession()
|
|
if (!session?.user) return null
|
|
|
|
const result = await db
|
|
.select({
|
|
username: schema.users.username,
|
|
githubUrl: schema.users.githubUrl,
|
|
xUrl: schema.users.xUrl,
|
|
youtubeUrl: schema.users.youtubeUrl,
|
|
emailNotifications: schema.users.emailNotifications,
|
|
})
|
|
.from(schema.users)
|
|
.where(eq(schema.users.id, session.user.id))
|
|
.limit(1)
|
|
|
|
return result[0] ?? null
|
|
}
|
|
|
|
/**
|
|
* Update the current user's social profile links
|
|
*/
|
|
export async function updateProfile(data: {
|
|
githubUrl?: string | null
|
|
xUrl?: string | null
|
|
youtubeUrl?: string | null
|
|
}): Promise<{ success: boolean; error?: string }> {
|
|
const session = await getSession()
|
|
if (!session?.user) {
|
|
return { success: false, error: 'Not authenticated' }
|
|
}
|
|
|
|
if (data.githubUrl && !/^https:\/\/(www\.)?github\.com\/.+/.test(data.githubUrl)) {
|
|
return { success: false, error: 'Invalid GitHub URL' }
|
|
}
|
|
if (data.xUrl && !/^https:\/\/(www\.)?(x|twitter)\.com\/.+/.test(data.xUrl)) {
|
|
return { success: false, error: 'Invalid X/Twitter URL' }
|
|
}
|
|
if (data.youtubeUrl && !/^https:\/\/(www\.)?(youtube\.com|youtu\.be)\/.+/.test(data.youtubeUrl)) {
|
|
return { success: false, error: 'Invalid YouTube URL' }
|
|
}
|
|
|
|
await db
|
|
.update(schema.users)
|
|
.set({
|
|
githubUrl: data.githubUrl ?? null,
|
|
xUrl: data.xUrl ?? null,
|
|
youtubeUrl: data.youtubeUrl ?? null,
|
|
})
|
|
.where(eq(schema.users.id, session.user.id))
|
|
|
|
revalidatePath('/settings')
|
|
return { success: true }
|
|
}
|
|
|
|
/**
|
|
* Get a user's public profile by username
|
|
*/
|
|
export async function getPublicProfile(username: string): Promise<{
|
|
success: boolean
|
|
data?: {
|
|
id: string
|
|
name: string
|
|
image: string | null
|
|
username: string
|
|
githubUrl: string | null
|
|
xUrl: string | null
|
|
youtubeUrl: string | null
|
|
}
|
|
error?: string
|
|
}> {
|
|
const result = await db
|
|
.select({
|
|
id: schema.users.id,
|
|
name: schema.users.name,
|
|
image: schema.users.image,
|
|
username: schema.users.username,
|
|
githubUrl: schema.users.githubUrl,
|
|
xUrl: schema.users.xUrl,
|
|
youtubeUrl: schema.users.youtubeUrl,
|
|
})
|
|
.from(schema.users)
|
|
.where(sql`lower(${schema.users.username}) = lower(${username})`)
|
|
.limit(1)
|
|
|
|
const user = result[0]
|
|
if (!user || !user.username) {
|
|
return { success: false, error: 'User not found' }
|
|
}
|
|
|
|
return { success: true, data: user as typeof user & { username: string } }
|
|
}
|
|
|
|
/**
|
|
* Get connected accounts for the current user
|
|
*/
|
|
export async function getConnectedAccounts(): Promise<
|
|
{ providerId: string; accountId: string }[]
|
|
> {
|
|
const session = await getSession()
|
|
if (!session?.user) return []
|
|
|
|
const result = await db
|
|
.select({
|
|
providerId: schema.accounts.providerId,
|
|
accountId: schema.accounts.accountId,
|
|
})
|
|
.from(schema.accounts)
|
|
.where(eq(schema.accounts.userId, session.user.id))
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Upload avatar image to Supabase Storage and update user record
|
|
*/
|
|
export async function uploadAvatar(
|
|
formData: FormData,
|
|
): Promise<{ success: boolean; imageUrl?: string; error?: string }> {
|
|
const session = await getSession()
|
|
if (!session?.user) {
|
|
return { success: false, error: 'Not authenticated' }
|
|
}
|
|
|
|
const file = formData.get('avatar') as File | null
|
|
if (!file) {
|
|
return { success: false, error: 'No file provided' }
|
|
}
|
|
|
|
if (file.size > 5 * 1024 * 1024) {
|
|
return { success: false, error: 'File too large (max 5MB)' }
|
|
}
|
|
|
|
if (!file.type.startsWith('image/')) {
|
|
return { success: false, error: 'File must be an image' }
|
|
}
|
|
|
|
const supabase = await createServerSupabaseClient()
|
|
const ext = file.name.split('.').pop() || 'png'
|
|
const filename = `${session.user.id}/avatar.${ext}`
|
|
|
|
const { data: uploadData, error: uploadError } = await supabase.storage
|
|
.from('avatars')
|
|
.upload(filename, file, {
|
|
contentType: file.type,
|
|
upsert: true,
|
|
})
|
|
|
|
if (uploadError) {
|
|
return { success: false, error: `Upload failed: ${uploadError.message}` }
|
|
}
|
|
|
|
const { data: urlData } = supabase.storage.from('avatars').getPublicUrl(uploadData.path)
|
|
const imageUrl = `${urlData.publicUrl}?t=${Date.now()}`
|
|
|
|
// Update user image in database
|
|
await db
|
|
.update(schema.users)
|
|
.set({ image: imageUrl })
|
|
.where(eq(schema.users.id, session.user.id))
|
|
|
|
revalidatePath('/')
|
|
revalidatePath('/settings')
|
|
return { success: true, imageUrl }
|
|
}
|
|
|
|
/**
|
|
* Update the current user's email notification preference
|
|
*/
|
|
export async function updateEmailNotifications(
|
|
enabled: boolean,
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
const session = await getSession()
|
|
if (!session?.user) {
|
|
return { success: false, error: 'Not authenticated' }
|
|
}
|
|
|
|
await db
|
|
.update(schema.users)
|
|
.set({ emailNotifications: enabled })
|
|
.where(eq(schema.users.id, session.user.id))
|
|
|
|
revalidatePath('/settings')
|
|
return { success: true }
|
|
}
|