Files
musicshare/packages/shared/src/schemas.ts
T
chrisandClaude Opus 5.5 f112a3515c Notifications and sending songs to friends
Reactions to your songs, new followers, and songs sent to you are now
notifications: a bell on the feed (app and website) with an unread
count that updates live, and a list that marks them read. "Send ✉" on
a song sends it to people who follow you, with an optional note.
Settings picks which kinds to be notified about.

Push delivery uses no third-party accounts:
- Android: UnifiedPush through a distributor app (ntfy), pointed at
  our own ntfy server (docker-compose.ntfy.yml). Works the same in
  the F-Droid build; no Firebase.
- Browsers: standard Web Push from the existing service worker.
Both use Web Push encryption with a VAPID key pair the server makes
and keeps in the database. Push addresses are checked like any other
user-supplied URL. Setup is in docs/PUSH.md; the privacy page covers
the new data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:14:34 -04:00

119 lines
4.3 KiB
TypeScript

import { z } from 'zod';
import { STREAMING_SERVICES } from './services';
export const usernameSchema = z
.string()
.trim()
.toLowerCase()
.regex(/^[a-z0-9_]{3,32}$/, 'Username must be 3-32 characters: letters, numbers, underscores');
// Trim before validating: iOS keyboards often append a space after autocompleting an email.
const emailSchema = z.string().trim().toLowerCase().pipe(z.email());
const newPasswordSchema = z.string().min(8, 'Password must be at least 8 characters').max(200);
export const registerSchema = z.object({
username: usernameSchema,
email: emailSchema,
password: newPasswordSchema,
});
export const forgotPasswordSchema = z.object({ email: emailSchema });
export const verifyEmailSchema = z.object({ token: z.string().min(20).max(200) });
export const changePasswordSchema = z.object({
currentPassword: z.string().min(1).max(200),
newPassword: newPasswordSchema,
});
export const changeEmailSchema = z.object({
email: emailSchema,
password: z.string().min(1).max(200),
});
export const resetPasswordSchema = z.object({
token: z.string().min(20).max(200),
password: newPasswordSchema,
});
export const loginSchema = z.object({
email: emailSchema,
password: z.string().min(1).max(200),
});
export const updateMeSchema = z.object({
displayName: z.string().trim().max(64).nullable().optional(),
bio: z.string().trim().max(280).nullable().optional(),
preferredService: z.enum(STREAMING_SERVICES).nullable().optional(),
services: z
.array(z.enum(STREAMING_SERVICES))
.max(STREAMING_SERVICES.length)
.transform((s) => [...new Set(s)])
.nullable()
.optional(),
notifyPrefs: z
.object({ reaction: z.boolean(), follow: z.boolean(), share: z.boolean() })
.partial()
.optional(),
/** Mark onboarding as finished. */
onboarded: z.literal(true).optional(),
});
/** Send a song to people who follow you. */
export const sendSongSchema = z.object({
trackId: z.uuid(),
to: z.array(z.string().min(1).max(32)).min(1).max(20),
message: z.string().trim().max(280).nullable().optional(),
});
/** A browser's or phone's push subscription (both use Web Push encryption keys). */
export const pushSubscriptionSchema = z.object({
kind: z.enum(['web', 'unifiedpush']),
endpoint: z.url({ protocol: /^https?$/ }).max(2000),
keys: z.object({ p256dh: z.string().min(20).max(200), auth: z.string().min(8).max(100) }),
});
export const setFeaturedSchema = z.union([
z.object({ deezerId: z.string().regex(/^\d+$/), comment: z.string().trim().max(280).nullable().optional() }),
z.object({ trackId: z.uuid(), comment: z.string().trim().max(280).nullable().optional() }),
]);
export const connectUsernameSchema = z.object({
username: z.string().trim().min(1).max(64),
});
export type RegisterInput = z.infer<typeof registerSchema>;
export type LoginInput = z.infer<typeof loginSchema>;
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
export type ChangePasswordInput = z.infer<typeof changePasswordSchema>;
export type ChangeEmailInput = z.infer<typeof changeEmailSchema>;
export type UpdateMeInput = z.infer<typeof updateMeSchema>;
export type SetFeaturedInput = z.infer<typeof setFeaturedSchema>;
export type SendSongInput = z.infer<typeof sendSongSchema>;
export type PushSubscriptionInput = z.infer<typeof pushSubscriptionSchema>;
export const jellyfinConnectSchema = z.object({
serverUrl: z.string().trim().min(1).max(500),
username: z.string().trim().min(1).max(128),
password: z.string().max(256).default(''),
});
/** Pause sharing: for an hour, until tomorrow, until turned back on, or resume (null). */
export const PAUSE_OPTIONS = ['1h', '8h', 'indefinitely'] as const;
export const setSharingSchema = z.object({ pause: z.enum(PAUSE_OPTIONS).nullable() });
export const REPORT_REASONS = ['spam', 'harassment', 'inappropriate', 'impersonation', 'other'] as const;
export type ReportReason = (typeof REPORT_REASONS)[number];
export const REPORT_REASON_LABELS: Record<ReportReason, string> = {
spam: 'Spam or fake account',
harassment: 'Harassment or bullying',
inappropriate: 'Inappropriate profile or picture',
impersonation: 'Pretending to be someone else',
other: 'Something else',
};
export const reportSchema = z.object({
reason: z.enum(REPORT_REASONS),
note: z.string().trim().max(500).nullish(),
});