Compare commits

...

7 Commits

35 changed files with 1898 additions and 56 deletions

View File

@@ -27,3 +27,14 @@ S3_VIRTUAL_HOST=false
# Email (Brevo) # Email (Brevo)
BREVO_API_KEY=your-brevo-api-key BREVO_API_KEY=your-brevo-api-key
# ClickHouse (analytics / metrics)
# single connection URL (overrides all individual vars below)
CLICKHOUSE_URL=http://default:password@localhost:8123/mixer_metrics
# individual vars (used when CLICKHOUSE_URL is not set)
CLICKHOUSE_HOST=localhost
CLICKHOUSE_PORT=8123
CLICKHOUSE_DATABASE=mixer_metrics
CLICKHOUSE_USERNAME=default
CLICKHOUSE_PASSWORD=
CLICKHOUSE_SCHEME=http

View File

@@ -1056,6 +1056,159 @@ html, body {
.mx-compose-wrapper { display: none; } .mx-compose-wrapper { display: none; }
} }
/* ── Avatar image ── */
.mx-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
display: block;
}
/* ── Tweet sub-handle (@username) ── */
.mx-tweet-subhandle {
font-size: 0.78rem;
color: var(--mx-muted);
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 120px;
}
/* ── Profile editor ── */
.mx-profile-editor {
padding: 1.5rem 1.25rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
max-width: 480px;
}
.mx-profile-avatar-section {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.mx-profile-avatar-wrap {
position: relative;
width: 80px;
height: 80px;
}
.mx-profile-avatar-img {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
display: block;
border: 2px solid var(--mx-border2);
}
.mx-profile-avatar-placeholder {
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(135deg, var(--mx-accent) 0%, var(--mx-accent2) 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
font-weight: 700;
color: white;
user-select: none;
}
.mx-profile-avatar-edit-btn {
position: absolute;
bottom: 0;
right: 0;
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--mx-accent);
border: 2px solid var(--mx-bg);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.mx-profile-avatar-edit-btn:hover { background: var(--mx-accent2); }
.mx-profile-avatar-edit-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.mx-profile-stats {
display: flex;
gap: 1.25rem;
font-size: 0.875rem;
color: var(--mx-muted);
}
.mx-profile-stats strong { color: var(--mx-fg); }
.mx-profile-field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.mx-profile-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--mx-fg2);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.mx-profile-input {
background: var(--mx-surface);
border: 1px solid var(--mx-border2);
border-radius: var(--mx-radius-sm);
padding: 0.5rem 0.75rem;
color: var(--mx-fg);
font-family: inherit;
font-size: 0.9375rem;
width: 100%;
transition: border-color 0.15s;
outline: none;
}
.mx-profile-input:focus { border-color: var(--mx-accent); }
.mx-profile-input--readonly { color: var(--mx-muted); cursor: not-allowed; }
.mx-profile-input-wrap {
display: flex;
align-items: center;
background: var(--mx-surface);
border: 1px solid var(--mx-border2);
border-radius: var(--mx-radius-sm);
padding: 0 0.75rem;
transition: border-color 0.15s;
}
.mx-profile-input-wrap:focus-within { border-color: var(--mx-accent); }
.mx-profile-at {
color: var(--mx-muted);
font-size: 0.9375rem;
pointer-events: none;
user-select: none;
}
.mx-profile-input--handle {
border: none;
border-radius: 0;
padding-left: 0.25rem;
background: transparent;
}
.mx-profile-input--handle:focus { border-color: transparent; }
.mx-profile-hint {
font-size: 0.72rem;
color: var(--mx-muted);
margin-top: 0.125rem;
}
/* Narrow phones (≤ 640 px): tighten spacing */ /* Narrow phones (≤ 640 px): tighten spacing */
@media (max-width: 640px) { @media (max-width: 640px) {
.mx-feed { padding: 0.625rem; gap: 0.5rem; } .mx-feed { padding: 0.625rem; gap: 0.5rem; }

View File

@@ -541,6 +541,83 @@ export async function validateReadUser(
} }
export type UpdateProfileInput = {
username?: string | null;
displayName?: string | null;
};
export type UpdateProfileFields = UnifiedFieldSelection<usersResourceSchema>[];
export type InferUpdateProfileResult<
Fields extends UpdateProfileFields | undefined,
> = InferResult<usersResourceSchema, Fields>;
export type UpdateProfileResult<Fields extends UpdateProfileFields | undefined = undefined> = | { success: true; data: InferUpdateProfileResult<Fields>; }
| { success: false; errors: AshRpcError[]; }
;
/**
* Update an existing User
*
* @ashActionType :update
*/
export async function updateProfile<Fields extends UpdateProfileFields | undefined = undefined>(
config: {
tenant?: string;
identity: UUID;
input?: UpdateProfileInput;
fields?: Fields;
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}
): Promise<UpdateProfileResult<Fields extends undefined ? [] : Fields>> {
const payload = {
action: "update_profile",
...(config.tenant !== undefined && { tenant: config.tenant }),
identity: config.identity,
input: config.input,
...(config.fields !== undefined && { fields: config.fields })
};
return executeActionRpcRequest<UpdateProfileResult<Fields extends undefined ? [] : Fields>>(
payload,
config
);
}
/**
* Validate: Update an existing User
*
* @ashActionType :update
* @validation true
*/
export async function validateUpdateProfile(
config: {
tenant?: string;
identity: UUID | string;
input?: UpdateProfileInput;
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}
): Promise<ValidationResult> {
const payload = {
action: "update_profile",
...(config.tenant !== undefined && { tenant: config.tenant }),
identity: config.identity,
input: config.input
};
return executeValidationRpcRequest<ValidationResult>(
payload,
config
);
}
export type ReadMediaFields = UnifiedFieldSelection<mediaResourceSchema>[]; export type ReadMediaFields = UnifiedFieldSelection<mediaResourceSchema>[];

View File

@@ -25,9 +25,12 @@ export type followsAttributesOnlySchema = {
// users Schema // users Schema
export type usersResourceSchema = { export type usersResourceSchema = {
__type: "Resource"; __type: "Resource";
__primitiveFields: "id" | "email" | "followerCount" | "followingCount" | "amIFollowing" | "myFollowId"; __primitiveFields: "id" | "email" | "username" | "displayName" | "avatarUrl" | "followerCount" | "followingCount" | "amIFollowing" | "myFollowId";
id: UUID; id: UUID;
email: string; email: string;
username: string | null;
displayName: string | null;
avatarUrl: string | null;
followerCount: number; followerCount: number;
followingCount: number; followingCount: number;
amIFollowing: boolean; amIFollowing: boolean;
@@ -38,9 +41,12 @@ export type usersResourceSchema = {
export type usersAttributesOnlySchema = { export type usersAttributesOnlySchema = {
__type: "Resource"; __type: "Resource";
__primitiveFields: "id" | "email"; __primitiveFields: "id" | "email" | "username" | "displayName" | "avatarUrl";
id: UUID; id: UUID;
email: string; email: string;
username: string | null;
displayName: string | null;
avatarUrl: string | null;
}; };
@@ -71,7 +77,7 @@ export type mediaAttributesOnlySchema = {
// tweets Schema // tweets Schema
export type tweetsResourceSchema = { export type tweetsResourceSchema = {
__type: "Resource"; __type: "Resource";
__primitiveFields: "id" | "content" | "likes" | "userId" | "insertedAt" | "state" | "parentTweetId" | "commentCount" | "likedByMe" | "userEmail"; __primitiveFields: "id" | "content" | "likes" | "userId" | "insertedAt" | "state" | "parentTweetId" | "commentCount" | "likedByMe" | "userEmail" | "userUsername" | "userDisplayName" | "userAvatarUrl";
id: UUID; id: UUID;
content: string; content: string;
likes: number; likes: number;
@@ -82,6 +88,9 @@ export type tweetsResourceSchema = {
commentCount: number; commentCount: number;
likedByMe: boolean; likedByMe: boolean;
userEmail: string | null; userEmail: string | null;
userUsername: string | null;
userDisplayName: string | null;
userAvatarUrl: string | null;
user: { __type: "Relationship"; __resource: usersResourceSchema; }; user: { __type: "Relationship"; __resource: usersResourceSchema; };
parentTweet: { __type: "Relationship"; __resource: tweetsResourceSchema | null; }; parentTweet: { __type: "Relationship"; __resource: tweetsResourceSchema | null; };
comments: { __type: "Relationship"; __array: true; __resource: tweetsResourceSchema; }; comments: { __type: "Relationship"; __array: true; __resource: tweetsResourceSchema; };
@@ -134,6 +143,27 @@ export type usersFilterInput = {
in?: Array<string>; in?: Array<string>;
}; };
username?: {
eq?: string;
notEq?: string;
in?: Array<string>;
isNil?: boolean;
};
displayName?: {
eq?: string;
notEq?: string;
in?: Array<string>;
isNil?: boolean;
};
avatarUrl?: {
eq?: string;
notEq?: string;
in?: Array<string>;
isNil?: boolean;
};
followerCount?: { followerCount?: {
eq?: number; eq?: number;
notEq?: number; notEq?: number;
@@ -270,6 +300,27 @@ export type tweetsFilterInput = {
isNil?: boolean; isNil?: boolean;
}; };
userUsername?: {
eq?: string;
notEq?: string;
in?: Array<string>;
isNil?: boolean;
};
userDisplayName?: {
eq?: string;
notEq?: string;
in?: Array<string>;
isNil?: boolean;
};
userAvatarUrl?: {
eq?: string;
notEq?: string;
in?: Array<string>;
isNil?: boolean;
};
commentCount?: { commentCount?: {
eq?: number; eq?: number;
notEq?: number; notEq?: number;
@@ -301,26 +352,26 @@ export type tweetsFilterInput = {
export const followsFilterFields = ["id"] as const; export const followsFilterFields = ["id"] as const;
export type followsFilterField = (typeof followsFilterFields)[number]; export type followsFilterField = (typeof followsFilterFields)[number];
export const usersFilterFields = ["id", "email", "followerCount", "followingCount", "amIFollowing", "myFollowId"] as const; export const usersFilterFields = ["id", "email", "username", "displayName", "avatarUrl", "followerCount", "followingCount", "amIFollowing", "myFollowId"] as const;
export type usersFilterField = (typeof usersFilterFields)[number]; export type usersFilterField = (typeof usersFilterFields)[number];
export const mediaFilterFields = ["id", "s3Key", "userId", "tweetId", "user", "tweet"] as const; export const mediaFilterFields = ["id", "s3Key", "userId", "tweetId", "user", "tweet"] as const;
export type mediaFilterField = (typeof mediaFilterFields)[number]; export type mediaFilterField = (typeof mediaFilterFields)[number];
export const tweetsFilterFields = ["id", "content", "likes", "userId", "insertedAt", "state", "parentTweetId", "userEmail", "commentCount", "likedByMe", "user", "parentTweet", "comments", "media"] as const; export const tweetsFilterFields = ["id", "content", "likes", "userId", "insertedAt", "state", "parentTweetId", "userEmail", "userUsername", "userDisplayName", "userAvatarUrl", "commentCount", "likedByMe", "user", "parentTweet", "comments", "media"] as const;
export type tweetsFilterField = (typeof tweetsFilterFields)[number]; export type tweetsFilterField = (typeof tweetsFilterFields)[number];
export const followsSortFields = ["id"] as const; export const followsSortFields = ["id"] as const;
export type followsSortField = (typeof followsSortFields)[number]; export type followsSortField = (typeof followsSortFields)[number];
export const usersSortFields = ["id", "email", "followerCount", "followingCount", "amIFollowing", "myFollowId"] as const; export const usersSortFields = ["id", "email", "username", "displayName", "avatarUrl", "followerCount", "followingCount", "amIFollowing", "myFollowId"] as const;
export type usersSortField = (typeof usersSortFields)[number]; export type usersSortField = (typeof usersSortFields)[number];
export const mediaSortFields = ["id", "s3Key", "userId", "tweetId"] as const; export const mediaSortFields = ["id", "s3Key", "userId", "tweetId"] as const;
export type mediaSortField = (typeof mediaSortFields)[number]; export type mediaSortField = (typeof mediaSortFields)[number];
export const tweetsSortFields = ["id", "content", "likes", "userId", "insertedAt", "state", "parentTweetId", "userEmail", "commentCount", "likedByMe"] as const; export const tweetsSortFields = ["id", "content", "likes", "userId", "insertedAt", "state", "parentTweetId", "userEmail", "userUsername", "userDisplayName", "userAvatarUrl", "commentCount", "likedByMe"] as const;
export type tweetsSortField = (typeof tweetsSortFields)[number]; export type tweetsSortField = (typeof tweetsSortFields)[number];

View File

@@ -17,12 +17,13 @@ import {
likeTweet, likeTweet,
unlikeTweet, unlikeTweet,
updateTweet, updateTweet,
updateProfile,
readUser, readUser,
followUser, followUser,
unfollowUser, unfollowUser,
buildCSRFHeaders, buildCSRFHeaders,
} from "./ash_rpc"; } from "./ash_rpc";
import { uploadFile } from "./upload"; import { uploadFile, uploadAvatar } from "./upload";
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 10_000 } }, defaultOptions: { queries: { staleTime: 10_000 } },
@@ -33,6 +34,9 @@ const queryClient = new QueryClient({
type User = { type User = {
id: string; id: string;
email: string; email: string;
username?: string | null;
displayName?: string | null;
avatarUrl?: string | null;
followerCount?: number; followerCount?: number;
followingCount?: number; followingCount?: number;
amIFollowing?: boolean; amIFollowing?: boolean;
@@ -50,12 +54,21 @@ type Tweet = {
state: string; state: string;
media?: MediaItem[]; media?: MediaItem[];
userEmail?: string | null; userEmail?: string | null;
userUsername?: string | null;
userDisplayName?: string | null;
userAvatarUrl?: string | null;
insertedAt?: string | null; insertedAt?: string | null;
}; };
// ── Auth context ─────────────────────────────────────────────────────────────── // ── Auth context ───────────────────────────────────────────────────────────────
const AuthCtx = createContext({ email: "", userId: "" }); const AuthCtx = createContext({
email: "",
userId: "",
username: "",
displayName: "",
avatarUrl: "",
});
// ── Responsive helper ───────────────────────────────────────────────────────── // ── Responsive helper ─────────────────────────────────────────────────────────
// Returns true when the viewport is wider than 960 px (desktop layout). // Returns true when the viewport is wider than 960 px (desktop layout).
@@ -102,6 +115,54 @@ function getAssetHost(): string {
return appEl?.dataset.assetHost ?? "http://localhost:9000"; return appEl?.dataset.assetHost ?? "http://localhost:9000";
} }
// ── Display-name helpers ─────────────────────────────────────────────
function userDisplayLabel(u: {
displayName?: string | null;
username?: string | null;
email?: string | null;
}): string {
return u.displayName || u.username || u.email || "@mixer";
}
function userHandle(u: { username?: string | null; email?: string | null }): string {
return u.username ? `@${u.username}` : u.email ?? "@mixer";
}
// ── Avatar ───────────────────────────────────────────────────────────────
function Avatar({
avatarUrl,
name,
size = "md",
}: {
avatarUrl?: string | null;
name?: string | null;
size?: "sm" | "md" | "lg";
}) {
const assetHost = getAssetHost();
const initial = ((name ?? "")[0] || "M").toUpperCase();
const cls = size === "sm"
? "mx-tweet-avatar mx-tweet-avatar--sm"
: size === "lg"
? "mx-tweet-avatar mx-tweet-avatar--lg"
: "mx-tweet-avatar";
return (
<div className={cls}>
{avatarUrl ? (
<img
src={`${assetHost}/${avatarUrl}`}
alt={name ?? "avatar"}
className="mx-avatar-img"
/>
) : (
<span>{initial}</span>
)}
</div>
);
}
// ── Context menu ────────────────────────────────────────────────────────────── // ── Context menu ──────────────────────────────────────────────────────────────
type ContextMenuItem = type ContextMenuItem =
@@ -203,6 +264,7 @@ function CharCount({ current, max }: { current: number; max: number }) {
} }
function ComposeTweet({ onSuccess }: { onSuccess?: () => void }) { function ComposeTweet({ onSuccess }: { onSuccess?: () => void }) {
const { username, displayName, email, avatarUrl } = useContext(AuthCtx);
const [text, setText] = useState(""); const [text, setText] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [pendingFile, setPendingFile] = useState<File | null>(null); const [pendingFile, setPendingFile] = useState<File | null>(null);
@@ -304,9 +366,7 @@ function ComposeTweet({ onSuccess }: { onSuccess?: () => void }) {
return ( return (
<div className="mx-compose"> <div className="mx-compose">
<div className="mx-compose-avatar"> <Avatar avatarUrl={avatarUrl} name={displayName || username || email} />
<span>M</span>
</div>
<div className="mx-compose-body"> <div className="mx-compose-body">
<textarea <textarea
ref={textareaRef} ref={textareaRef}
@@ -546,12 +606,13 @@ function TweetCard({ tweet }: { tweet: Tweet }) {
onClick={() => { window.location.href = `/feed/${tweet.id}`; }} onClick={() => { window.location.href = `/feed/${tweet.id}`; }}
onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY }); }} onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY }); }}
> >
<div className="mx-tweet-avatar"> <Avatar avatarUrl={tweet.userAvatarUrl} name={tweet.userDisplayName || tweet.userUsername || tweet.userEmail} />
<span>M</span>
</div>
<div className="mx-tweet-body"> <div className="mx-tweet-body">
<div className="mx-tweet-header"> <div className="mx-tweet-header">
<span className="mx-tweet-handle">{tweet.userEmail ?? "@mixer"}</span> <span className="mx-tweet-handle">{userDisplayLabel({ displayName: tweet.userDisplayName, username: tweet.userUsername, email: tweet.userEmail })}</span>
{tweet.userUsername && (
<span className="mx-tweet-subhandle">@{tweet.userUsername}</span>
)}
<span className="mx-tweet-dot">·</span> <span className="mx-tweet-dot">·</span>
<span className="mx-tweet-time" title={tweet.insertedAt ? new Date(tweet.insertedAt).toLocaleString() : undefined}>{timeAgo(tweet.insertedAt)}</span> <span className="mx-tweet-time" title={tweet.insertedAt ? new Date(tweet.insertedAt).toLocaleString() : undefined}>{timeAgo(tweet.insertedAt)}</span>
{canModify && ( {canModify && (
@@ -754,11 +815,11 @@ function ComposeComment({ parentTweetId, onSuccess }: { parentTweetId: string; o
el.style.height = `${el.scrollHeight}px`; el.style.height = `${el.scrollHeight}px`;
}, [text]); }, [text]);
const { username, displayName, email, avatarUrl } = useContext(AuthCtx);
return ( return (
<div className="mx-compose mx-compose--comment"> <div className="mx-compose mx-compose--comment">
<div className="mx-compose-avatar mx-compose-avatar--sm"> <Avatar avatarUrl={avatarUrl} name={displayName || username || email} size="sm" />
<span>M</span>
</div>
<div className="mx-compose-body"> <div className="mx-compose-body">
<textarea <textarea
ref={textareaRef} ref={textareaRef}
@@ -803,7 +864,7 @@ function TweetDetail({ tweetId }: { tweetId: string }) {
queryKey: ["tweet", tweetId], queryKey: ["tweet", tweetId],
queryFn: async () => { queryFn: async () => {
const res = await readTweet({ const res = await readTweet({
fields: ["id", "content", "likes", "likedByMe", "commentCount", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }], fields: ["id", "content", "likes", "likedByMe", "commentCount", "userId", "state", "userEmail", "userUsername", "userDisplayName", "userAvatarUrl", "insertedAt", { media: ["id", "s3Key"] }],
filter: { id: { eq: tweetId } }, filter: { id: { eq: tweetId } },
headers: buildCSRFHeaders(), headers: buildCSRFHeaders(),
}); });
@@ -825,7 +886,7 @@ function TweetDetail({ tweetId }: { tweetId: string }) {
queryKey: ["comments", tweetId], queryKey: ["comments", tweetId],
queryFn: async ({ pageParam }: { pageParam: number }) => { queryFn: async ({ pageParam }: { pageParam: number }) => {
const res = await readTweet({ const res = await readTweet({
fields: ["id", "content", "likes", "likedByMe", "parentTweetId", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }], fields: ["id", "content", "likes", "likedByMe", "parentTweetId", "userId", "state", "userEmail", "userUsername", "userDisplayName", "userAvatarUrl", "insertedAt", { media: ["id", "s3Key"] }],
filter: { parentTweetId: { eq: tweetId } }, filter: { parentTweetId: { eq: tweetId } },
sort: "insertedAt", sort: "insertedAt",
page: { limit: COMMENTS_PAGE_SIZE, offset: pageParam }, page: { limit: COMMENTS_PAGE_SIZE, offset: pageParam },
@@ -950,10 +1011,13 @@ function TweetDetail({ tweetId }: { tweetId: string }) {
<div className="mx-detail-body"> <div className="mx-detail-body">
<div className="mx-detail-author"> <div className="mx-detail-author">
<div className="mx-tweet-avatar"> <Avatar avatarUrl={tweet.userAvatarUrl} name={tweet.userDisplayName || tweet.userUsername || tweet.userEmail} />
<span>M</span> <div>
<span className="mx-tweet-handle">{userDisplayLabel({ displayName: tweet.userDisplayName, username: tweet.userUsername, email: tweet.userEmail })}</span>
{tweet.userUsername && (
<div style={{ fontSize: "0.8rem", color: "var(--mx-muted)" }}>@{tweet.userUsername}</div>
)}
</div> </div>
<span className="mx-tweet-handle">{tweet.userEmail ?? "@mixer"}</span>
</div> </div>
{editing ? ( {editing ? (
@@ -1091,12 +1155,13 @@ function CommentCard({ comment, parentTweetOwnerId }: { comment: Tweet; parentTw
return ( return (
<article className="mx-tweet mx-comment"> <article className="mx-tweet mx-comment">
<div className="mx-tweet-avatar mx-tweet-avatar--sm"> <Avatar avatarUrl={comment.userAvatarUrl} name={comment.userDisplayName || comment.userUsername || comment.userEmail} size="sm" />
<span>M</span>
</div>
<div className="mx-tweet-body"> <div className="mx-tweet-body">
<div className="mx-tweet-header"> <div className="mx-tweet-header">
<span className="mx-tweet-handle">{comment.userEmail ?? "@mixer"}</span> <span className="mx-tweet-handle">{userDisplayLabel({ displayName: comment.userDisplayName, username: comment.userUsername, email: comment.userEmail })}</span>
{comment.userUsername && (
<span className="mx-tweet-subhandle">@{comment.userUsername}</span>
)}
<span className="mx-tweet-dot">·</span> <span className="mx-tweet-dot">·</span>
<span className="mx-tweet-time" title={comment.insertedAt ? new Date(comment.insertedAt).toLocaleString() : undefined}>{timeAgo(comment.insertedAt)}</span> <span className="mx-tweet-time" title={comment.insertedAt ? new Date(comment.insertedAt).toLocaleString() : undefined}>{timeAgo(comment.insertedAt)}</span>
{canModify && ( {canModify && (
@@ -1169,7 +1234,7 @@ function FollowingFeed() {
queryKey: ["following_tweets"], queryKey: ["following_tweets"],
queryFn: async ({ pageParam }: { pageParam: number }) => { queryFn: async ({ pageParam }: { pageParam: number }) => {
const res = await readFollowingFeed({ const res = await readFollowingFeed({
fields: ["id", "content", "likes", "likedByMe", "commentCount", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }], fields: ["id", "content", "likes", "likedByMe", "commentCount", "userId", "state", "userEmail", "userUsername", "userDisplayName", "userAvatarUrl", "insertedAt", { media: ["id", "s3Key"] }],
sort: "-insertedAt", sort: "-insertedAt",
page: { limit: FEED_PAGE_SIZE, offset: pageParam }, page: { limit: FEED_PAGE_SIZE, offset: pageParam },
filter: { parentTweetId: { isNil: true } }, filter: { parentTweetId: { isNil: true } },
@@ -1263,7 +1328,7 @@ function Feed() {
queryKey: ["tweets"], queryKey: ["tweets"],
queryFn: async ({ pageParam }: { pageParam: number }) => { queryFn: async ({ pageParam }: { pageParam: number }) => {
const res = await readTweet({ const res = await readTweet({
fields: ["id", "content", "likes", "likedByMe", "commentCount", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }], fields: ["id", "content", "likes", "likedByMe", "commentCount", "userId", "state", "userEmail", "userUsername", "userDisplayName", "userAvatarUrl", "insertedAt", { media: ["id", "s3Key"] }],
sort: "-insertedAt", sort: "-insertedAt",
page: { limit: FEED_PAGE_SIZE, offset: pageParam }, page: { limit: FEED_PAGE_SIZE, offset: pageParam },
filter: { parentTweetId: { isNil: true } }, filter: { parentTweetId: { isNil: true } },
@@ -1428,12 +1493,13 @@ function UserCard({ user }: { user: User }) {
onClick={() => { window.location.href = `/users/${user.id}`; }} onClick={() => { window.location.href = `/users/${user.id}`; }}
onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY }); }} onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY }); }}
> >
<div className="mx-tweet-avatar"> <Avatar avatarUrl={user.avatarUrl} name={user.displayName || user.username || user.email} />
<span>M</span>
</div>
<div className="mx-tweet-body"> <div className="mx-tweet-body">
<div className="mx-tweet-header"> <div className="mx-tweet-header">
<span className="mx-tweet-handle">{user.email}</span> <span className="mx-tweet-handle">{userDisplayLabel(user)}</span>
{user.username && (
<span className="mx-tweet-subhandle">@{user.username}</span>
)}
</div> </div>
{(user.followerCount !== undefined || user.followingCount !== undefined) && ( {(user.followerCount !== undefined || user.followingCount !== undefined) && (
<div className="mx-tweet-meta" style={{ fontSize: "0.8rem", color: "var(--mx-muted)", marginTop: "4px" }}> <div className="mx-tweet-meta" style={{ fontSize: "0.8rem", color: "var(--mx-muted)", marginTop: "4px" }}>
@@ -1459,7 +1525,7 @@ function UserList() {
queryKey: ["users"], queryKey: ["users"],
queryFn: async () => { queryFn: async () => {
const res = await readUser({ const res = await readUser({
fields: ["id", "email", "followerCount", "followingCount", "amIFollowing"], fields: ["id", "email", "username", "displayName", "avatarUrl", "followerCount", "followingCount", "amIFollowing"],
headers: buildCSRFHeaders(), headers: buildCSRFHeaders(),
}); });
if (!res.success) throw new Error("Failed to load users"); if (!res.success) throw new Error("Failed to load users");
@@ -1499,7 +1565,7 @@ function UserDetail({ userId, isStandalone = false }: { userId: string; isStanda
queryKey: ["user", userId], queryKey: ["user", userId],
queryFn: async () => { queryFn: async () => {
const res = await readUser({ const res = await readUser({
fields: ["id", "email", "followerCount", "followingCount", "amIFollowing"], fields: ["id", "email", "username", "displayName", "avatarUrl", "followerCount", "followingCount", "amIFollowing"],
filter: { id: { eq: userId } }, filter: { id: { eq: userId } },
headers: buildCSRFHeaders(), headers: buildCSRFHeaders(),
}); });
@@ -1530,27 +1596,212 @@ function UserDetail({ userId, isStandalone = false }: { userId: string; isStanda
)} )}
<div className="mx-detail-body"> <div className="mx-detail-body">
<div className="mx-detail-author"> <div className="mx-detail-author">
<div className="mx-tweet-avatar mx-tweet-avatar--lg"> <Avatar avatarUrl={user.avatarUrl} name={user.displayName || user.username || user.email} size="lg" />
<span>{user.email?.[0]?.toUpperCase() ?? "M"}</span>
</div>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: "12px", flexWrap: "wrap" }}> <div style={{ display: "flex", alignItems: "center", gap: "12px", flexWrap: "wrap" }}>
<span className="mx-tweet-handle">{user.email}</span> <div>
<div className="mx-tweet-handle" style={{ fontSize: "1.1rem" }}>{userDisplayLabel(user)}</div>
{user.username && (
<div style={{ fontSize: "0.85rem", color: "var(--mx-muted)" }}>@{user.username}</div>
)}
</div>
{canFollow && ( {canFollow && (
<FollowButton amIFollowing={amIFollowing} isPending={isPending} onToggle={amIFollowing ? unfollow : follow} /> <FollowButton amIFollowing={amIFollowing} isPending={isPending} onToggle={amIFollowing ? unfollow : follow} />
)} )}
{isOwnProfile && isStandalone && (
<a href="/sign-out" className="mx-btn-cancel" style={{ textDecoration: "none", fontSize: "0.8rem" }}>
Sign out
</a>
)}
</div> </div>
<div style={{ fontSize: "0.85rem", color: "var(--mx-muted)", marginTop: "6px", display: "flex", gap: "16px" }}> <div style={{ fontSize: "0.85rem", color: "var(--mx-muted)", marginTop: "8px", display: "flex", gap: "16px" }}>
<span><strong style={{ color: "var(--mx-fg)" }}>{user.followerCount ?? 0}</strong> followers</span>
<span><strong style={{ color: "var(--mx-fg)" }}>{user.followingCount ?? 0}</strong> following</span>
</div>
</div>
</div>
</div>
</div>
);
}
function ProfileEditor({ userId }: { userId: string }) {
const assetHost = getAssetHost();
const qc = useQueryClient();
const { data: user, isLoading } = useQuery({
queryKey: ["user", userId],
queryFn: async () => {
const res = await readUser({
fields: ["id", "email", "username", "displayName", "avatarUrl", "followerCount", "followingCount", "amIFollowing"],
filter: { id: { eq: userId } },
headers: buildCSRFHeaders(),
});
if (!res.success) throw new Error("Failed to load user");
const results = Array.isArray(res.data) ? res.data : (res.data as any)?.results ?? [];
return (results[0] as User) ?? null;
},
});
const [username, setUsername] = useState("");
const [displayName, setDisplayName] = useState("");
const [saveError, setSaveError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState(false);
const [avatarUploading, setAvatarUploading] = useState(false);
const [avatarError, setAvatarError] = useState<string | null>(null);
const [previewAvatarUrl, setPreviewAvatarUrl] = useState<string | null>(null);
const avatarInputRef = useRef<HTMLInputElement>(null);
// Sync form fields when user data loads
useEffect(() => {
if (user) {
setUsername(user.username ?? "");
setDisplayName(user.displayName ?? "");
}
}, [user?.id]);
const saveMutation = useMutation({
mutationFn: async () => {
const res = await updateProfile({
identity: userId,
input: {
username: username.trim() || null,
displayName: displayName.trim() || null,
},
fields: ["id", "username", "displayName", "avatarUrl"],
headers: buildCSRFHeaders(),
});
if (!res.success) throw new Error((res.errors?.[0] as any)?.message ?? "Save failed");
return res.data;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["user", userId] });
setSaveSuccess(true);
setSaveError(null);
setTimeout(() => setSaveSuccess(false), 3000);
},
onError: (e: Error) => setSaveError(e.message),
});
async function handleAvatarChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = "";
if (previewAvatarUrl) URL.revokeObjectURL(previewAvatarUrl);
setPreviewAvatarUrl(URL.createObjectURL(file));
setAvatarError(null);
setAvatarUploading(true);
const csrfToken = buildCSRFHeaders()["X-CSRF-Token"] as string;
const result = await uploadAvatar(file, csrfToken);
setAvatarUploading(false);
if ("error" in result) {
setAvatarError(result.error);
if (previewAvatarUrl) URL.revokeObjectURL(previewAvatarUrl);
setPreviewAvatarUrl(null);
} else {
qc.invalidateQueries({ queryKey: ["user", userId] });
}
}
if (isLoading || !user) return <Spinner />;
const currentAvatarUrl = previewAvatarUrl
? previewAvatarUrl
: user.avatarUrl
? `${assetHost}/${user.avatarUrl}`
: null;
return (
<div className="mx-profile-editor">
{/* Avatar section */}
<div className="mx-profile-avatar-section">
<div className="mx-profile-avatar-wrap">
{currentAvatarUrl ? (
<img src={currentAvatarUrl} alt="Your avatar" className="mx-profile-avatar-img" />
) : (
<div className="mx-profile-avatar-placeholder">
<span>{(user.displayName || user.username || user.email || "M")[0].toUpperCase()}</span>
</div>
)}
<button
className="mx-profile-avatar-edit-btn"
onClick={() => avatarInputRef.current?.click()}
disabled={avatarUploading}
title="Change avatar"
>
{avatarUploading ? (
<div className="mx-spinner" style={{ width: "14px", height: "14px", borderWidth: "2px" }} />
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zm17.71-10.04a1 1 0 0 0 0-1.41l-2.31-2.31a1 1 0 0 0-1.41 0l-1.79 1.79 3.75 3.75 1.76-1.82z" />
</svg>
)}
</button>
<input
ref={avatarInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleAvatarChange}
/>
</div>
{avatarError && <p className="mx-compose-error" style={{ marginTop: "0.5rem" }}>{avatarError}</p>}
</div>
{/* Stats row */}
<div className="mx-profile-stats">
<span><strong>{user.followerCount ?? 0}</strong> followers</span> <span><strong>{user.followerCount ?? 0}</strong> followers</span>
<span><strong>{user.followingCount ?? 0}</strong> following</span> <span><strong>{user.followingCount ?? 0}</strong> following</span>
</div> </div>
{/* Email (read-only) */}
<div className="mx-profile-field">
<label className="mx-profile-label">Email</label>
<input
type="text"
className="mx-profile-input mx-profile-input--readonly"
value={String(user.email)}
readOnly
/>
</div> </div>
{/* Display name */}
<div className="mx-profile-field">
<label className="mx-profile-label">Display name</label>
<input
type="text"
className="mx-profile-input"
placeholder="Your display name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
maxLength={50}
/>
</div> </div>
{/* Username */}
<div className="mx-profile-field">
<label className="mx-profile-label">Username</label>
<div className="mx-profile-input-wrap">
<span className="mx-profile-at">@</span>
<input
type="text"
className="mx-profile-input mx-profile-input--handle"
placeholder="your_handle"
value={username}
onChange={(e) => setUsername(e.target.value.replace(/[^a-zA-Z0-9_]/g, ""))}
maxLength={30}
/>
</div>
<p className="mx-profile-hint">330 characters. Letters, numbers, underscores only.</p>
</div>
{saveError && <p className="mx-compose-error">{saveError}</p>}
{saveSuccess && <p style={{ fontSize: "0.8rem", color: "var(--mx-green)", marginBottom: "0.5rem" }}> Saved!</p>}
<div style={{ display: "flex", gap: "0.75rem", alignItems: "center" }}>
<button
className="mx-btn-post"
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? "Saving…" : "Save changes"}
</button>
<a href="/sign-out" className="mx-btn-cancel" style={{ textDecoration: "none" }}>Sign out</a>
</div> </div>
</div> </div>
); );
@@ -1572,7 +1823,7 @@ function MyProfile() {
); );
} }
return <UserDetail userId={userId} isStandalone />; return <ProfileEditor userId={userId} />;
} }
// ── Mobile bottom nav ───────────────────────────────────────────────────────── // ── Mobile bottom nav ─────────────────────────────────────────────────────────
@@ -1691,6 +1942,9 @@ function App() {
const appEl = document.getElementById("app")!; const appEl = document.getElementById("app")!;
const email = appEl.dataset.currentUserEmail ?? ""; const email = appEl.dataset.currentUserEmail ?? "";
const userId = appEl.dataset.currentUserId ?? ""; const userId = appEl.dataset.currentUserId ?? "";
const username = appEl.dataset.currentUserUsername ?? "";
const displayName = appEl.dataset.currentUserDisplayName ?? "";
const avatarUrl = appEl.dataset.currentUserAvatarUrl ?? "";
const tweetId = appEl.dataset.tweetId || null; const tweetId = appEl.dataset.tweetId || null;
const page = appEl.dataset.page ?? "feed"; const page = appEl.dataset.page ?? "feed";
const profileUserId = appEl.dataset.userId || null; const profileUserId = appEl.dataset.userId || null;
@@ -1779,7 +2033,7 @@ function App() {
} }
return ( return (
<AuthCtx.Provider value={{ email, userId }}> <AuthCtx.Provider value={{ email, userId, username, displayName, avatarUrl }}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<div className="mx-root"> <div className="mx-root">
{isDesktop && ( {isDesktop && (
@@ -1817,7 +2071,12 @@ function App() {
<div className="mx-sidebar-footer"> <div className="mx-sidebar-footer">
{email ? ( {email ? (
<> <>
<span className="mx-version" style={{ color: "var(--mx-fg2)" }}>{email}</span> <span className="mx-version" style={{ color: "var(--mx-fg2)" }}>
{displayName || username || email}
</span>
{username && (
<span className="mx-version">@{username}</span>
)}
<a className="mx-auth-link" href="/sign-out">Sign out</a> <a className="mx-auth-link" href="/sign-out">Sign out</a>
</> </>
) : ( ) : (

View File

@@ -9,6 +9,29 @@ export interface UploadError {
error: string; error: string;
} }
export interface AvatarUploadResult {
success: true;
avatarUrl: string;
}
export async function uploadAvatar(
file: File,
csrfToken: string
): Promise<AvatarUploadResult | UploadError> {
const formData = new FormData();
formData.append("file", file);
const res = await fetch("/upload/avatar", {
method: "POST",
headers: { "X-CSRF-Token": csrfToken },
body: formData,
});
const json = await res.json();
if (!res.ok || !json.success) {
return { error: json.error ?? "Upload failed" };
}
return json as AvatarUploadResult;
}
export async function uploadFile( export async function uploadFile(
file: File, file: File,
csrfToken: string csrfToken: string

View File

@@ -94,7 +94,7 @@ config :spark,
] ]
config :mixer, config :mixer,
ecto_repos: [Mixer.Repo], ecto_repos: [Mixer.Repo, Mixer.ClickhouseRepo],
generators: [timestamp_type: :utc_datetime], generators: [timestamp_type: :utc_datetime],
ash_domains: [Mixer.Accounts, Mixer.Posts], ash_domains: [Mixer.Accounts, Mixer.Posts],
ash_authentication: [return_error_on_invalid_magic_link_token?: true] ash_authentication: [return_error_on_invalid_magic_link_token?: true]
@@ -158,6 +158,11 @@ config :logger, :default_formatter,
# Use Jason for JSON parsing in Phoenix # Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason config :phoenix, :json_library, Jason
# ClickHouse repo — migrations live in priv/clickhouse/migrations
config :mixer, Mixer.ClickhouseRepo,
priv: "priv/clickhouse",
migration_source: "ch_schema_migrations"
# Import environment specific config. This must remain at the bottom # Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above. # of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs" import_config "#{config_env()}.exs"

View File

@@ -106,3 +106,12 @@ config :ex_aws, :s3,
config :waffle, config :waffle,
bucket: "mixer-bucket", bucket: "mixer-bucket",
asset_host: "http://localhost:9000" asset_host: "http://localhost:9000"
# ClickHouse (default local install)
config :mixer, Mixer.ClickhouseRepo,
scheme: "http",
hostname: "localhost",
port: 8123,
database: "mixer_metrics",
username: "default",
password: ""

View File

@@ -22,6 +22,11 @@ end
config :mixer, MixerWeb.Endpoint, http: [port: String.to_integer(System.get_env("PORT", "4000"))] config :mixer, MixerWeb.Endpoint, http: [port: String.to_integer(System.get_env("PORT", "4000"))]
# ClickHouse is available in all environments via env vars when set
if clickhouse_url = System.get_env("CLICKHOUSE_URL") do
config :mixer, Mixer.ClickhouseRepo, url: clickhouse_url
end
if config_env() == :prod do if config_env() == :prod do
database_url = database_url =
System.get_env("DATABASE_URL") || System.get_env("DATABASE_URL") ||
@@ -40,6 +45,19 @@ if config_env() == :prod do
# pool_count: 4, # pool_count: 4,
socket_options: maybe_ipv6 socket_options: maybe_ipv6
# ClickHouse — configure via CLICKHOUSE_URL or individual vars
unless System.get_env("CLICKHOUSE_URL") do
config :mixer, Mixer.ClickhouseRepo,
scheme: System.get_env("CLICKHOUSE_SCHEME", "http"),
hostname:
System.get_env("CLICKHOUSE_HOST") ||
raise("Missing environment variable `CLICKHOUSE_HOST`!"),
port: String.to_integer(System.get_env("CLICKHOUSE_PORT", "8123")),
database: System.get_env("CLICKHOUSE_DATABASE", "mixer_metrics"),
username: System.get_env("CLICKHOUSE_USERNAME", "default"),
password: System.get_env("CLICKHOUSE_PASSWORD", "")
end
# The secret key base is used to sign/encrypt cookies and other secrets. # The secret key base is used to sign/encrypt cookies and other secrets.
# A default value is used in config/dev.exs and config/test.exs but you # A default value is used in config/dev.exs and config/test.exs but you
# want to use a different value for prod and you most likely don't want # want to use a different value for prod and you most likely don't want

View File

@@ -42,3 +42,12 @@ config :phoenix_live_view,
# Sort query params output of verified routes for robust url comparisons # Sort query params output of verified routes for robust url comparisons
config :phoenix, config :phoenix,
sort_verified_routes_query_params: true sort_verified_routes_query_params: true
# ClickHouse — point at a dedicated test database
config :mixer, Mixer.ClickhouseRepo,
scheme: "http",
hostname: "localhost",
port: 8123,
database: "mixer_metrics_test",
username: "default",
password: ""

View File

@@ -4,6 +4,7 @@ defmodule Mixer.Accounts do
typescript_rpc do typescript_rpc do
resource Mixer.Accounts.User do resource Mixer.Accounts.User do
rpc_action :read_user, :read rpc_action :read_user, :read
rpc_action :update_profile, :update_profile
end end
resource Mixer.Accounts.Follow do resource Mixer.Accounts.Follow do

View File

@@ -0,0 +1,33 @@
defmodule Mixer.Accounts.AvatarUploader do
use Waffle.Definition
@versions [:original, :thumb]
@extensions ~w(.jpg .jpeg .png .gif .webp)
def validate({file, _scope}) do
ext = file.file_name |> Path.extname() |> String.downcase()
if ext in @extensions, do: :ok, else: {:error, "unsupported file type #{ext}"}
end
# Resize to a 256×256 square (centre-crop) and convert to WebP for efficiency
def transform(:thumb, _) do
{:convert, "-strip -thumbnail 256x256^ -gravity center -extent 256x256 -format webp", :webp}
end
# Store both versions under avatars/:user_id/
def storage_dir(_version, {_file, scope}), do: "avatars/#{scope.user_id}"
def filename(:original, {file, _scope}) do
Path.basename(file.file_name, Path.extname(file.file_name))
end
def filename(:thumb, _), do: "thumb"
def s3_object_headers(:thumb, _), do: [content_type: "image/webp"]
def s3_object_headers(_version, {file, _scope}) do
[content_type: MIME.from_path(file.file_name)]
end
def acl(_version, _), do: :public_read
end

View File

@@ -177,9 +177,21 @@ defmodule Mixer.Accounts.User do
sensitive? true sensitive? true
end end
argument :username, :string do
description "The desired username for the user (letters, numbers, underscores)."
allow_nil? false
constraints match: ~r/^[a-zA-Z0-9_]+$/,
min_length: 3,
max_length: 30
end
# Sets the email from the argument # Sets the email from the argument
change set_attribute(:email, arg(:email)) change set_attribute(:email, arg(:email))
# Sets the username from the argument
change set_attribute(:username, arg(:username))
# Hashes the provided password # Hashes the provided password
change AshAuthentication.Strategy.Password.HashPasswordChange change AshAuthentication.Strategy.Password.HashPasswordChange
@@ -211,6 +223,18 @@ defmodule Mixer.Accounts.User do
get_by :email get_by :email
end end
update :update_profile do
description "Update the user's public profile (username, display name)."
accept [:username, :display_name]
require_atomic? false
end
update :update_avatar do
description "Store the S3 key of the user's processed avatar thumbnail."
accept [:avatar_url]
require_atomic? false
end
update :reset_password_with_token do update :reset_password_with_token do
argument :reset_token, :string do argument :reset_token, :string do
allow_nil? false allow_nil? false
@@ -256,6 +280,15 @@ defmodule Mixer.Accounts.User do
allow_nil? true allow_nil? true
end end
argument :username, :string do
description "Username chosen during first-time magic link registration."
allow_nil? true
constraints match: ~r/^[a-zA-Z0-9_]+$/,
min_length: 3,
max_length: 30
end
upsert? true upsert? true
upsert_identity :unique_email upsert_identity :unique_email
upsert_fields [:email] upsert_fields [:email]
@@ -266,6 +299,37 @@ defmodule Mixer.Accounts.User do
change {AshAuthentication.Strategy.RememberMe.MaybeGenerateTokenChange, change {AshAuthentication.Strategy.RememberMe.MaybeGenerateTokenChange,
strategy_name: :remember_me} strategy_name: :remember_me}
# Set username on new users (or existing users who haven't set one yet)
change fn changeset, _ctx ->
case Ash.Changeset.get_argument(changeset, :username) do
nil ->
changeset
username ->
# Set the attribute directly so the unique_username identity's
# eager_check_with fires during Form.validate, surfacing "already
# taken" errors in the UI before the action is submitted.
changeset = Ash.Changeset.change_attribute(changeset, :username, username)
# Also update via after_action to handle existing users who have no
# username yet: for upserts, only upsert_fields are applied to the
# conflicting row, so change_attribute above won't touch them.
Ash.Changeset.after_action(changeset, fn _cs, user ->
if is_nil(user.username) do
user
|> Ash.Changeset.for_update(:update_profile, %{username: username},authorize?: false)
|> Ash.update()
|> case do
{:ok, updated} -> {:ok, updated}
{:error, error} -> {:error, error}
end
else
{:ok, user}
end
end)
end
end
metadata :token, :string do metadata :token, :string do
allow_nil? false allow_nil? false
end end
@@ -293,6 +357,14 @@ defmodule Mixer.Accounts.User do
policy action_type(:read) do policy action_type(:read) do
authorize_if always() authorize_if always()
end end
policy action(:update_profile) do
authorize_if expr(id == ^actor(:id))
end
policy action(:update_avatar) do
authorize_if expr(id == ^actor(:id))
end
end end
attributes do attributes do
@@ -308,6 +380,23 @@ defmodule Mixer.Accounts.User do
end end
attribute :confirmed_at, :utc_datetime_usec attribute :confirmed_at, :utc_datetime_usec
attribute :username, :string do
public? true
constraints match: ~r/^[a-zA-Z0-9_]+$/,
min_length: 3,
max_length: 30
end
attribute :display_name, :string do
public? true
constraints max_length: 50
end
attribute :avatar_url, :string do
public? true
end
end end
relationships do relationships do
@@ -350,5 +439,10 @@ defmodule Mixer.Accounts.User do
identities do identities do
identity :unique_email, [:email] identity :unique_email, [:email]
identity :unique_username, [:username] do
eager_check_with Mixer.Accounts
message "is already taken"
nils_distinct? true
end
end end
end end

View File

@@ -10,6 +10,10 @@ defmodule Mixer.Application do
children = [ children = [
MixerWeb.Telemetry, MixerWeb.Telemetry,
Mixer.Repo, Mixer.Repo,
# ClickHouse repo for analytics — started before the metrics buffer
Mixer.ClickhouseRepo,
# In-memory event buffer that batches writes to ClickHouse
Mixer.Metrics.Buffer,
{DNSCluster, query: Application.get_env(:mixer, :dns_cluster_query) || :ignore}, {DNSCluster, query: Application.get_env(:mixer, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Mixer.PubSub}, {Phoenix.PubSub, name: Mixer.PubSub},
# Start a worker by calling: Mixer.Worker.start_link(arg) # Start a worker by calling: Mixer.Worker.start_link(arg)

View File

@@ -0,0 +1,13 @@
defmodule Mixer.ClickhouseRepo do
@moduledoc """
Ecto repository for ClickHouse, backed by the `ecto_ch` / `Ch` adapter.
Used exclusively for analytics writes (via `Mixer.Metrics.Buffer`) and
read queries (via `Mixer.Metrics`). It is **not** an Ash repo and must
never be used for transactional application data.
"""
use Ecto.Repo,
otp_app: :mixer,
adapter: Ecto.Adapters.ClickHouse
end

291
lib/mixer/metrics.ex Normal file
View File

@@ -0,0 +1,291 @@
defmodule Mixer.Metrics do
@moduledoc """
Public API for tracking and querying post (tweet) metrics via ClickHouse.
## Tracking events
Tracking calls are non-blocking — events are handed off to the in-memory
`Mixer.Metrics.Buffer` GenServer and written to ClickHouse in batches.
# Record a tweet view (anonymous)
Mixer.Metrics.track_view(tweet_id)
# Record a view with a logged-in user and their IP
Mixer.Metrics.track_view(tweet_id, user_id: user.id, ip_address: conn.remote_ip)
## Querying metrics
Query functions execute synchronous ClickHouse SQL and return plain maps.
{:ok, summary} = Mixer.Metrics.get_summary(tweet_id)
# => %{views: 42, likes: 7, unlikes: 1, comments: 3, shares: 0}
{:ok, rows} = Mixer.Metrics.get_top_posts(10)
# => [%{tweet_id: "...", views: 99}, ...]
"""
require Logger
alias Mixer.ClickhouseRepo
alias Mixer.Metrics.Buffer
# ---------------------------------------------------------------------------
# Event types
# ---------------------------------------------------------------------------
@type event_type ::
:view | :post | :comment | :like | :unlike | :share | :delete_post | :delete_comment
@type track_opt ::
{:user_id, binary() | nil}
| {:ip_address, binary() | :inet.ip_address() | nil}
# ---------------------------------------------------------------------------
# Tracking helpers
# ---------------------------------------------------------------------------
@doc """
Track a tweet view event.
## Options
* `:user_id` — UUID of the viewing user (nil for anonymous)
* `:ip_address` — originating IP; accepts a string or an `:inet` tuple
"""
@spec track_view(binary(), [track_opt()]) :: :ok
def track_view(tweet_id, opts \\ []), do: enqueue("view", tweet_id, opts)
@doc "Track a tweet like event."
@spec track_like(binary(), [track_opt()]) :: :ok
def track_like(tweet_id, opts \\ []), do: enqueue("like", tweet_id, opts)
@doc "Track a tweet unlike event."
@spec track_unlike(binary(), [track_opt()]) :: :ok
def track_unlike(tweet_id, opts \\ []), do: enqueue("unlike", tweet_id, opts)
@doc "Track a comment (reply) event on a tweet."
@spec track_comment(binary(), [track_opt()]) :: :ok
def track_comment(tweet_id, opts \\ []), do: enqueue("comment", tweet_id, opts)
@doc "Track a tweet share / repost event."
@spec track_share(binary(), [track_opt()]) :: :ok
def track_share(tweet_id, opts \\ []), do: enqueue("share", tweet_id, opts)
@doc """
Track a new top-level tweet being published.
The event is recorded against the new tweet's own ID.
"""
@spec track_post(binary(), [track_opt()]) :: :ok
def track_post(tweet_id, opts \\ []), do: enqueue("post", tweet_id, opts)
@doc """
Track a top-level tweet being deleted.
The event is recorded against the deleted tweet's ID.
Note: cascade-deleted comments are not individually tracked — only the
explicit user-initiated destroy action emits this event.
"""
@spec track_delete_post(binary(), [track_opt()]) :: :ok
def track_delete_post(tweet_id, opts \\ []), do: enqueue("delete_post", tweet_id, opts)
@doc """
Track a comment (reply) being deleted.
The event is recorded against the *parent* tweet's ID so that
`get_summary/1` can reflect net comment activity on a tweet.
"""
@spec track_delete_comment(binary(), [track_opt()]) :: :ok
def track_delete_comment(tweet_id, opts \\ []), do: enqueue("delete_comment", tweet_id, opts)
# ---------------------------------------------------------------------------
# Query helpers
# ---------------------------------------------------------------------------
@doc """
Return a summary of all event counts for a single tweet.
Returns `{:ok, map}` on success or `{:error, reason}` on failure.
## Example
{:ok, %{views: 12, likes: 3, unlikes: 0, comments: 5, shares: 1}} =
Mixer.Metrics.get_summary(tweet_id)
"""
@spec get_summary(binary()) :: {:ok, map()} | {:error, term()}
def get_summary(tweet_id) do
sql = """
SELECT
countIf(event_type = 'view') AS views,
countIf(event_type = 'like') AS likes,
countIf(event_type = 'unlike') AS unlikes,
countIf(event_type = 'comment') AS comments,
countIf(event_type = 'share') AS shares
FROM post_events
WHERE tweet_id = {tweet_id:String}
"""
case ClickhouseRepo.query(sql, %{"tweet_id" => tweet_id}) do
{:ok, result} ->
{:ok, row_to_summary(result)}
{:error, reason} ->
Logger.error("[Mixer.Metrics] get_summary failed for #{tweet_id}: #{inspect(reason)}")
{:error, reason}
end
end
@doc """
Return view counts bucketed by UTC hour for the past `hours` hours.
Useful for rendering a sparkline on a tweet detail page.
## Example
{:ok, rows} = Mixer.Metrics.get_hourly_views(tweet_id, 24)
# => [%{hour: ~N[2026-04-07 00:00:00], views: 5}, ...]
"""
@spec get_hourly_views(binary(), pos_integer()) :: {:ok, [map()]} | {:error, term()}
def get_hourly_views(tweet_id, hours \\ 24) when is_integer(hours) and hours > 0 do
sql = """
SELECT
toStartOfHour(occurred_at) AS hour,
count() AS views
FROM post_events
WHERE
tweet_id = {tweet_id:String}
AND event_type = 'view'
AND occurred_at >= now() - toIntervalHour({hours:UInt32})
GROUP BY hour
ORDER BY hour ASC
"""
case ClickhouseRepo.query(sql, %{"tweet_id" => tweet_id, "hours" => hours}) do
{:ok, %{rows: rows}} ->
{:ok, Enum.map(rows, fn [hour, views] -> %{hour: hour, views: views} end)}
{:error, reason} ->
Logger.error("[Mixer.Metrics] get_hourly_views failed: #{inspect(reason)}")
{:error, reason}
end
end
@doc """
Return the top `limit` tweets ordered by total view count across all time.
## Example
{:ok, rows} = Mixer.Metrics.get_top_posts(10)
# => [%{tweet_id: "...", views: 99}, %{tweet_id: "...", views: 72}, ...]
"""
@spec get_top_posts(pos_integer()) :: {:ok, [map()]} | {:error, term()}
def get_top_posts(limit \\ 10) when is_integer(limit) and limit > 0 do
sql = """
SELECT
tweet_id,
countIf(event_type = 'view') AS views
FROM post_events
GROUP BY tweet_id
ORDER BY views DESC
LIMIT {limit:UInt32}
"""
case ClickhouseRepo.query(sql, %{"limit" => limit}) do
{:ok, %{rows: rows}} ->
{:ok, Enum.map(rows, fn [tweet_id, views] -> %{tweet_id: tweet_id, views: views} end)}
{:error, reason} ->
Logger.error("[Mixer.Metrics] get_top_posts failed: #{inspect(reason)}")
{:error, reason}
end
end
@doc """
Return per-event-type counts for a list of tweet IDs in a single query.
Handy for batch-enriching a feed with metrics without N+1 queries.
## Example
{:ok, map} = Mixer.Metrics.get_bulk_summaries(tweet_ids)
# => %{"<uuid>" => %{views: 5, likes: 2, ...}, ...}
"""
@spec get_bulk_summaries([binary()]) :: {:ok, %{binary() => map()}} | {:error, term()}
def get_bulk_summaries([]), do: {:ok, %{}}
def get_bulk_summaries(tweet_ids) when is_list(tweet_ids) do
# ecto_ch supports passing arrays as query parameters
sql = """
SELECT
tweet_id,
countIf(event_type = 'view') AS views,
countIf(event_type = 'like') AS likes,
countIf(event_type = 'unlike') AS unlikes,
countIf(event_type = 'comment') AS comments,
countIf(event_type = 'share') AS shares
FROM post_events
WHERE tweet_id IN {tweet_ids:Array(String)}
GROUP BY tweet_id
"""
case ClickhouseRepo.query(sql, %{"tweet_ids" => tweet_ids}) do
{:ok, %{rows: rows}} ->
summaries =
Map.new(rows, fn [tweet_id, views, likes, unlikes, comments, shares] ->
{tweet_id,
%{
views: views,
likes: likes,
unlikes: unlikes,
comments: comments,
shares: shares
}}
end)
{:ok, summaries}
{:error, reason} ->
Logger.error("[Mixer.Metrics] get_bulk_summaries failed: #{inspect(reason)}")
{:error, reason}
end
end
# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------
defp enqueue(event_type, tweet_id, opts) do
event = %{
event_type: event_type,
tweet_id: tweet_id,
user_id: Keyword.get(opts, :user_id),
occurred_at: DateTime.utc_now() |> DateTime.truncate(:second),
ip_address: opts |> Keyword.get(:ip_address) |> format_ip()
}
Buffer.track(event)
end
defp format_ip(nil), do: nil
defp format_ip(ip) when is_binary(ip), do: ip
defp format_ip({a, b, c, d}), do: "#{a}.#{b}.#{c}.#{d}"
defp format_ip({a, b, c, d, e, f, g, h}) do
[a, b, c, d, e, f, g, h]
|> Enum.map_join(":", &Integer.to_string(&1, 16))
end
defp row_to_summary(%{rows: [[views, likes, unlikes, comments, shares] | _]}) do
%{
views: views,
likes: likes,
unlikes: unlikes,
comments: comments,
shares: shares
}
end
# ClickHouse returns no rows when the tweet has zero events — default to 0
defp row_to_summary(_), do: %{views: 0, likes: 0, unlikes: 0, comments: 0, shares: 0}
end

151
lib/mixer/metrics/buffer.ex Normal file
View File

@@ -0,0 +1,151 @@
defmodule Mixer.Metrics.Buffer do
@moduledoc """
GenServer that accumulates post metric events in memory and flushes them
to ClickHouse in batches.
Two conditions trigger a flush:
1. **Timer** — every `@flush_interval` milliseconds (default 10 s).
2. **Threshold** — whenever the in-memory buffer reaches `@max_buffer_size`
rows (default 500).
If ClickHouse is unavailable the error is logged and the buffered events
are discarded rather than retried indefinitely, preventing unbounded memory
growth. For production deployments that require durability, consider adding
a persistent queue in front of this buffer.
"""
use GenServer
require Logger
alias Mixer.Metrics.PostEvent
@flush_interval :timer.seconds(10)
@max_buffer_size 500
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
@doc """
Start the buffer process and link it to the calling process.
Accepts an optional keyword list of overrides:
* `:flush_interval` — milliseconds between scheduled flushes
* `:max_buffer_size` — row count that triggers an immediate flush
"""
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Enqueue a single analytics event map for buffered insertion into ClickHouse.
The map must contain at minimum the fields required by `Mixer.Metrics.PostEvent`:
`:event_type`, `:tweet_id`, `:occurred_at`. Other fields are optional.
This call is asynchronous (cast) and returns `:ok` immediately.
"""
@spec track(map()) :: :ok
def track(event) when is_map(event) do
GenServer.cast(__MODULE__, {:track, event})
end
@doc """
Force an immediate flush of all buffered events to ClickHouse, regardless
of the timer or threshold. Returns `:ok` after the flush completes.
Primarily useful in tests.
"""
@spec flush() :: :ok
def flush do
GenServer.call(__MODULE__, :flush)
end
# ---------------------------------------------------------------------------
# GenServer callbacks
# ---------------------------------------------------------------------------
@impl GenServer
def init(opts) do
flush_interval = Keyword.get(opts, :flush_interval, @flush_interval)
max_buffer_size = Keyword.get(opts, :max_buffer_size, @max_buffer_size)
schedule_flush(flush_interval)
state = %{
events: [],
count: 0,
flush_interval: flush_interval,
max_buffer_size: max_buffer_size
}
{:ok, state}
end
@impl GenServer
def handle_cast({:track, event}, state) do
new_count = state.count + 1
new_events = [event | state.events]
if new_count >= state.max_buffer_size do
do_flush(new_events)
{:noreply, %{state | events: [], count: 0}}
else
{:noreply, %{state | events: new_events, count: new_count}}
end
end
@impl GenServer
def handle_call(:flush, _from, state) do
do_flush(state.events)
{:reply, :ok, %{state | events: [], count: 0}}
end
@impl GenServer
def handle_info(:flush, state) do
do_flush(state.events)
schedule_flush(state.flush_interval)
{:noreply, %{state | events: [], count: 0}}
end
@impl GenServer
def terminate(_reason, state) do
# Best-effort flush on shutdown so we don't lose buffered events during
# graceful stops (e.g., deploys).
do_flush(state.events)
:ok
end
# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------
defp do_flush([]), do: :ok
defp do_flush(events) do
rows = Enum.reverse(events)
count = length(rows)
try do
# ClickHouse async inserts acknowledge writes immediately and always
# return num_rows: 0 — the data is queued for background commitment.
# We use our own row count for the log so it is always accurate.
Mixer.ClickhouseRepo.insert_all(PostEvent, rows)
Logger.debug("[Mixer.Metrics.Buffer] Flushed #{count} event(s) to ClickHouse")
rescue
error ->
Logger.error(
"[Mixer.Metrics.Buffer] Failed to flush #{count} event(s) to ClickHouse: " <>
Exception.message(error)
)
end
end
defp schedule_flush(interval) do
Process.send_after(self(), :flush, interval)
end
end

View File

@@ -0,0 +1,47 @@
defmodule Mixer.Metrics.PostEvent do
@moduledoc """
Ecto schema that maps to the `post_events` table in ClickHouse.
Each row represents a single analytics event tied to a tweet (post).
The table uses a MergeTree engine ordered by `(occurred_at, event_type,
tweet_id)` for efficient time-range scans and per-tweet aggregations.
## Event types
| event_type | `tweet_id` refers to | Description |
|--------------------|-----------------------|-------------------------------------------------|
| `"view"` | the viewed tweet | Tweet detail page was loaded |
| `"post"` | the new tweet | A new top-level tweet was published |
| `"comment"` | the parent tweet | A reply was posted; count against the parent |
| `"like"` | the liked tweet | A user liked a tweet |
| `"unlike"` | the unliked tweet | A user removed their like |
| `"share"` | the shared tweet | A user shared / reposted a tweet |
| `"delete_post"` | the deleted tweet | A top-level tweet was deleted by its author |
| `"delete_comment"` | the parent tweet | A reply was deleted; count against the parent |
"""
use Ecto.Schema
@primary_key false
schema "post_events" do
# Must be Ch-typed so ecto_ch emits LowCardinality(String) in the RowBinary
# header, matching the ClickHouse table DDL exactly.
field :event_type, Ch, type: "LowCardinality(String)"
# The tweet that the event relates to
field :tweet_id, Ecto.UUID
# The acting user; may be nil for anonymous views.
# Must be Ch-typed so ecto_ch emits Nullable(UUID) in the RowBinary header,
# matching the ClickHouse table DDL exactly.
field :user_id, Ch, type: "Nullable(UUID)"
# Wall-clock time of the event (UTC, second precision)
field :occurred_at, :utc_datetime
# Optional originating IP, useful for deduplicating anonymous views.
# Nullable(String) for the same reason as user_id above.
field :ip_address, Ch, type: "Nullable(String)"
end
end

View File

@@ -32,7 +32,7 @@ defmodule Mixer.Posts.Tweet do
end end
actions do actions do
defaults [:read, :destroy] defaults [:read]
read :following_feed do read :following_feed do
filter expr( filter expr(
@@ -66,6 +66,49 @@ defmodule Mixer.Posts.Tweet do
end) end)
end end
end end
# Track post / comment creation metrics.
# Root tweets emit a "post" event recorded against their own ID.
# Replies emit a "comment" event recorded against the parent tweet ID so
# that `get_summary/1` can count how many replies a tweet has received.
change fn changeset, context ->
parent_tweet_id = Ash.Changeset.get_attribute(changeset, :parent_tweet_id)
user_id = context.actor && context.actor.id
Ash.Changeset.after_action(changeset, fn _changeset, tweet ->
if parent_tweet_id do
Mixer.Metrics.track_comment(parent_tweet_id, user_id: user_id)
else
Mixer.Metrics.track_post(tweet.id, user_id: user_id)
end
{:ok, tweet}
end)
end
end
# Explicit destroy so we can attach a metrics hook. The policy and cascade
# behaviour are identical to the previous default :destroy action.
destroy :destroy do
require_atomic? false
change fn changeset, context ->
# Capture the record's identity *before* deletion — after the action
# completes the row no longer exists.
tweet_id = changeset.data.id
parent_tweet_id = changeset.data.parent_tweet_id
user_id = context.actor && context.actor.id
Ash.Changeset.after_action(changeset, fn _changeset, result ->
if parent_tweet_id do
Mixer.Metrics.track_delete_comment(parent_tweet_id, user_id: user_id)
else
Mixer.Metrics.track_delete_post(tweet_id, user_id: user_id)
end
{:ok, result}
end)
end
end end
update :update do update :update do
@@ -80,6 +123,7 @@ defmodule Mixer.Posts.Tweet do
Ash.Changeset.after_action(changeset, fn _changeset, tweet -> Ash.Changeset.after_action(changeset, fn _changeset, tweet ->
case ensure_like(tweet, context.actor) do case ensure_like(tweet, context.actor) do
{:created, _like} -> {:created, _like} ->
Mixer.Metrics.track_like(tweet.id, user_id: context.actor && context.actor.id)
increment_likes(tweet, context.actor) increment_likes(tweet, context.actor)
{:noop, _like} -> {:noop, _like} ->
@@ -100,6 +144,7 @@ defmodule Mixer.Posts.Tweet do
Ash.Changeset.after_action(changeset, fn _changeset, tweet -> Ash.Changeset.after_action(changeset, fn _changeset, tweet ->
case remove_like(tweet, context.actor) do case remove_like(tweet, context.actor) do
{:deleted, _like} -> {:deleted, _like} ->
Mixer.Metrics.track_unlike(tweet.id, user_id: context.actor && context.actor.id)
decrement_likes(tweet, context.actor) decrement_likes(tweet, context.actor)
{:noop, _like} -> {:noop, _like} ->
@@ -209,6 +254,18 @@ defmodule Mixer.Posts.Tweet do
calculate :user_email, :string, expr(user.email) do calculate :user_email, :string, expr(user.email) do
public? true public? true
end end
calculate :user_username, :string, expr(user.username) do
public? true
end
calculate :user_display_name, :string, expr(user.display_name) do
public? true
end
calculate :user_avatar_url, :string, expr(user.avatar_url) do
public? true
end
end end
aggregates do aggregates do

View File

@@ -15,4 +15,9 @@ defmodule MixerWeb.AuthOverrides do
set :text, "⬡ Mixer" set :text, "⬡ Mixer"
set :text_class, "text-3xl font-bold tracking-tight" set :text_class, "text-3xl font-bold tracking-tight"
end end
# Inject the username field into the password registration form
override AshAuthentication.Phoenix.Components.Password do
set :register_extra_component, &MixerWeb.AuthComponents.username_field/1
end
end end

View File

@@ -0,0 +1,55 @@
defmodule MixerWeb.AuthComponents do
@moduledoc """
Extra components injected into AshAuthentication.Phoenix forms.
"""
use Phoenix.Component
@doc """
Renders a username input field inside the password registration form.
Receives `form` (an `AshPhoenix.Form`) as an assign via the
`register_extra_component` override.
"""
def username_field(assigns) do
field = assigns.form[:username]
assigns =
assigns
|> assign(:field_id, field.id)
|> assign(:field_name, field.name)
|> assign(:field_value, field.value || "")
|> assign(:field_errors, field.errors)
~H"""
<div class="mt-2 mb-2">
<label for={@field_id} class="block text-sm font-medium text-base-content mb-1">
Username
</label>
<div class="flex">
<span class="flex items-center justify-center px-4 bg-base-200 border border-base-300 border-r-0 rounded-l-lg text-base-content/50 select-none">@</span>
<input
type="text"
id={@field_id}
name={@field_name}
value={@field_value}
class={"input w-full rounded-l-none #{if @field_errors != [], do: "input-error", else: ""}"}
placeholder="your_handle"
required
/>
</div>
<p :for={error <- @field_errors} class="mt-1 text-xs text-error">
{translate_error(error)}
</p>
</div>
"""
end
def translate_error({msg, opts}) do
if count = opts[:count] do
Gettext.dngettext(MixerWeb.Gettext, "errors", msg, msg, count, opts)
else
Gettext.dgettext(MixerWeb.Gettext, "errors", msg, opts)
end
end
end

View File

@@ -16,6 +16,8 @@ defmodule MixerWeb.PageController do
end end
def show(conn, %{"tweet_id" => tweet_id}) do def show(conn, %{"tweet_id" => tweet_id}) do
user_id = conn.assigns[:current_user] && conn.assigns[:current_user].id
Mixer.Metrics.track_view(tweet_id, user_id: user_id, ip_address: conn.remote_ip)
render_spa(conn, %{page: "tweet", tweet_id: tweet_id, user_id: nil}) render_spa(conn, %{page: "tweet", tweet_id: tweet_id, user_id: nil})
end end

View File

@@ -2,6 +2,11 @@
id="app" id="app"
data-current-user-id={if @current_user, do: @current_user.id, else: ""} data-current-user-id={if @current_user, do: @current_user.id, else: ""}
data-current-user-email={if @current_user, do: @current_user.email, else: ""} data-current-user-email={if @current_user, do: @current_user.email, else: ""}
data-current-user-username={if @current_user, do: @current_user.username || "", else: ""}
data-current-user-display-name={
if @current_user, do: @current_user.display_name || "", else: ""
}
data-current-user-avatar-url={if @current_user, do: @current_user.avatar_url || "", else: ""}
data-asset-host={@media_host} data-asset-host={@media_host}
data-page={@page} data-page={@page}
data-tweet-id={@tweet_id || ""} data-tweet-id={@tweet_id || ""}

View File

@@ -2,6 +2,7 @@ defmodule MixerWeb.UploadController do
use MixerWeb, :controller use MixerWeb, :controller
alias Mixer.Posts.MediaUploader alias Mixer.Posts.MediaUploader
alias Mixer.Accounts.AvatarUploader
def create(conn, %{"file" => %Plug.Upload{} = upload}) do def create(conn, %{"file" => %Plug.Upload{} = upload}) do
actor = conn.assigns[:current_user] actor = conn.assigns[:current_user]
@@ -46,4 +47,48 @@ defmodule MixerWeb.UploadController do
|> put_status(:bad_request) |> put_status(:bad_request)
|> json(%{error: "no file provided"}) |> json(%{error: "no file provided"})
end end
# ── Avatar upload ──────────────────────────────────────────────────────────
def upload_avatar(conn, %{"file" => %Plug.Upload{} = upload}) do
actor = conn.assigns[:current_user]
unless actor do
conn
|> put_status(:unauthorized)
|> json(%{error: "authentication required"})
else
scope = %{user_id: actor.id}
case AvatarUploader.store({upload, scope}) do
{:ok, _file_name} ->
# The thumb is always stored as avatars/:user_id/thumb.webp
thumb_key = "avatars/#{actor.id}/thumb.webp"
actor
|> Ash.Changeset.for_update(:update_avatar, %{avatar_url: thumb_key}, actor: actor)
|> Ash.update()
|> case do
{:ok, _user} ->
json(conn, %{success: true, avatarUrl: thumb_key})
{:error, error} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{success: false, error: inspect(error)})
end
{:error, reason} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{success: false, error: reason})
end
end
end
def upload_avatar(conn, _params) do
conn
|> put_status(:bad_request)
|> json(%{error: "no file provided"})
end
end end

View File

@@ -0,0 +1,188 @@
defmodule MixerWeb.MagicSignInLive do
@moduledoc """
Custom magic-link sign-in LiveView that collects a username for new users.
When a user clicks their magic link, this page is shown instead of the
default auto-submit. If the user is brand new (no account) or has no
username set yet, we ask them to choose one before completing sign-in.
"""
use AshAuthentication.Phoenix.Overrides.Overridable,
root_class: "CSS class for the root `div` element.",
magic_sign_in_id: "Element ID for the `MagicSignIn` LiveComponent."
use AshAuthentication.Phoenix.Web, :live_view
alias AshAuthentication.Info
alias AshPhoenix.Form
alias Phoenix.LiveView.{Rendered, Socket}
import AshAuthentication.Phoenix.Components.Helpers, only: [auth_path: 5]
import PhoenixHTMLHelpers.Form, only: [hidden_input: 3, submit: 2]
import Slug
@doc false
@impl true
def mount(params, session, socket) do
overrides =
session
|> Map.get("overrides", [AshAuthentication.Phoenix.Overrides.Default])
resource = session["resource"]
strategy_name = session["strategy"]
token = params["token"] || params["magic_link"]
strategy = Info.strategy!(resource, strategy_name)
subject_name = Info.authentication_subject_name!(resource)
domain = Info.authentication_domain!(resource)
# Determine whether this user needs to pick a username
needs_username? = needs_username?(token, resource, domain)
form =
resource
|> Form.for_action(strategy.sign_in_action_name,
params: %{"token" => token},
domain: domain,
as: subject_name |> to_string(),
id: "#{subject_name}-#{strategy_name}-sign-in-form" |> slugify(),
context: %{strategy: strategy, private: %{ash_authentication?: true}}
)
socket =
socket
|> assign(overrides: overrides)
|> assign(:token, token)
|> assign(:strategy, strategy)
|> assign(:subject_name, subject_name)
|> assign(:resource, resource)
|> assign(:needs_username?, needs_username?)
|> assign(:form, form)
|> assign(:trigger_action, false)
|> assign(:current_tenant, session["tenant"])
|> assign(:auth_routes_prefix, session["auth_routes_prefix"])
{:ok, socket}
end
@doc false
@impl true
@spec handle_params(map, String.t(), Socket.t()) :: {:noreply, Socket.t()}
def handle_params(_params, _uri, socket), do: {:noreply, socket}
@doc false
@impl true
@spec render(Socket.assigns()) :: Rendered.t()
def render(assigns) do
~H"""
<div class="min-h-screen flex flex-col items-center justify-center bg-base-100 p-4">
<div class="w-full max-w-sm mb-8 text-center">
<.live_component
module={AshAuthentication.Phoenix.Components.Banner}
id="magic-sign-in-banner"
overrides={@overrides}
/>
</div>
<div class="w-full max-w-sm p-6 bg-base-100 border border-base-200 rounded-xl shadow-sm">
<.form :let={form} for={@form} phx-change="validate" phx-submit="submit" phx-trigger-action={@trigger_action}
action={auth_path(@socket, @subject_name, @auth_routes_prefix, @strategy, :sign_in)}
method="POST">
{hidden_input(form, :token, [])}
<%!-- Using the unified component --%>
<MixerWeb.AuthComponents.username_field :if={@needs_username?} form={form} />
{submit("Sign in", class: "btn btn-primary w-full", phx_disable_with: "Signing in...")}
</.form>
</div>
</div>
"""
end
@doc false
@impl true
@spec handle_event(String.t(), map(), Socket.t()) :: {:noreply, Socket.t()}
def handle_event("submit", params, socket) do
subject_name =
socket.assigns.subject_name
|> to_string()
|> slugify()
form_params = Map.get(params, subject_name, %{})
# Use Form.validate with :all_errors to surface uniqueness constraints
form =
socket.assigns.form
|> Form.validate(form_params, errors: true)
if form.valid? do
# Only trigger the POST redirect if the data is truly valid
{:noreply, assign(socket, form: form, trigger_action: true)}
else
socket =
socket
|> assign(form: form, trigger_action: false)
{:noreply, socket}
end
end
@impl true
def handle_event("validate", params, socket) do
subject_name = socket.assigns.subject_name |> to_string() |> slugify()
form_params = Map.get(params, subject_name, %{})
form = Form.validate(socket.assigns.form, form_params, errors: true)
{:noreply, assign(socket, form: form)}
end
# ── Helpers ──────────────────────────────────────────────────────────────────
# Returns true if the user is new or has no username set yet.
defp needs_username?(nil, _resource, _domain), do: true
defp needs_username?(token, resource, domain) do
with {:ok, claims} <- AshAuthentication.Jwt.peek(token),
# 1. Try to find an existing user from the claims
user <- find_user(claims, resource, domain),
# 2. If a user exists, check if they already have a username
false <- is_nil(user) do
is_nil(user.username) or user.username == ""
else
_ ->
# Unknown / new user — ask for username to be safe
true
end
end
defp find_user(claims, resource, domain) do
# Try 'sub' first if it looks like a user subject (e.g. "User:123")
sub = Map.get(claims, "sub")
user =
if is_binary(sub) and String.contains?(sub, ":") do
case AshAuthentication.subject_to_user(sub, resource) do
{:ok, user} -> user
_ -> nil
end
end
# If not found via subject, try 'identity' (common in magic link tokens)
user ||
case Map.get(claims, "identity") || Map.get(claims, "email") do
email when is_binary(email) ->
# Use for_read with the explicit action and arguments
resource
|> Ash.Query.for_read(:get_by_email, %{email: email})
|> Ash.read_one(domain: domain, authorize?: false)
|> case do
{:ok, user} -> user
_ -> nil
end
_ ->
nil
end
end
end

View File

@@ -47,6 +47,7 @@ defmodule MixerWeb.Router do
post "/rpc/run", AshTypescriptRpcController, :run post "/rpc/run", AshTypescriptRpcController, :run
post "/rpc/validate", AshTypescriptRpcController, :validate post "/rpc/validate", AshTypescriptRpcController, :validate
post "/upload", UploadController, :create post "/upload", UploadController, :create
post "/upload/avatar", UploadController, :upload_avatar
auth_routes AuthController, Mixer.Accounts.User, path: "/auth" auth_routes AuthController, Mixer.Accounts.User, path: "/auth"
sign_out_route AuthController sign_out_route AuthController
@@ -74,6 +75,7 @@ defmodule MixerWeb.Router do
# Remove this if you do not use the magic link strategy. # Remove this if you do not use the magic link strategy.
magic_sign_in_route(Mixer.Accounts.User, :magic_link, magic_sign_in_route(Mixer.Accounts.User, :magic_link,
live_view: MixerWeb.MagicSignInLive,
auth_routes_prefix: "/auth", auth_routes_prefix: "/auth",
overrides: [MixerWeb.AuthOverrides, Elixir.AshAuthentication.Phoenix.Overrides.DaisyUI] overrides: [MixerWeb.AuthOverrides, Elixir.AshAuthentication.Phoenix.Overrides.DaisyUI]
) )

View File

@@ -91,7 +91,8 @@ defmodule Mixer.MixProject do
{:ex_aws, "~> 2.1.2"}, {:ex_aws, "~> 2.1.2"},
{:ex_aws_s3, "~> 2.0"}, {:ex_aws_s3, "~> 2.0"},
{:hackney, "~> 1.9"}, {:hackney, "~> 1.9"},
{:sweet_xml, "~> 0.6"} {:sweet_xml, "~> 0.6"},
{:ecto_ch, "~> 0.3"}
] ]
end end

View File

@@ -20,6 +20,7 @@
"castore": {:hex, :castore, "1.0.18", "5e43ef0ec7d31195dfa5a65a86e6131db999d074179d2ba5a8de11fe14570f55", [:mix], [], "hexpm", "f393e4fe6317829b158fb74d86eb681f737d2fe326aa61ccf6293c4104957e34"}, "castore": {:hex, :castore, "1.0.18", "5e43ef0ec7d31195dfa5a65a86e6131db999d074179d2ba5a8de11fe14570f55", [:mix], [], "hexpm", "f393e4fe6317829b158fb74d86eb681f737d2fe326aa61ccf6293c4104957e34"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"},
"ch": {:hex, :ch, "0.7.1", "116c08094b30d095c3bd6a8fe4ebe19fdaaf3dce84e2413cfdd6af157baf6303", [:mix], [{:db_connection, "~> 2.9.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "3c1c900291ff9c4c077cd1dc0c265051a3f1d26320d58b37ed9e91b33d41a868"},
"cinder": {:hex, :cinder, "0.12.1", "02ae4988e025fb32c37e4e7f2e491586b952918c0dd99d856da13271cd680e16", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.3", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 1.0.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "a48b5677c1f57619d9d7564fb2bd7928f93750a2e8c0b1b145852a30ecf2aa20"}, "cinder": {:hex, :cinder, "0.12.1", "02ae4988e025fb32c37e4e7f2e491586b952918c0dd99d856da13271cd680e16", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.3", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 1.0.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "a48b5677c1f57619d9d7564fb2bd7928f93750a2e8c0b1b145852a30ecf2aa20"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"conv_case": {:hex, :conv_case, "0.2.3", "c1455c27d3c1ffcdd5f17f1e91f40b8a0bc0a337805a6e8302f441af17118ed8", [:mix], [], "hexpm", "88f29a3d97d1742f9865f7e394ed3da011abb7c5e8cc104e676fdef6270d4b4a"}, "conv_case": {:hex, :conv_case, "0.2.3", "c1455c27d3c1ffcdd5f17f1e91f40b8a0bc0a337805a6e8302f441af17118ed8", [:mix], [], "hexpm", "88f29a3d97d1742f9865f7e394ed3da011abb7c5e8cc104e676fdef6270d4b4a"},
@@ -29,6 +30,7 @@
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"dotenvy": {:hex, :dotenvy, "1.1.1", "00e318f3c51de9fafc4b48598447e386f19204dc18ca69886905bb8f8b08b667", [:mix], [], "hexpm", "c8269471b5701e9e56dc86509c1199ded2b33dce088c3471afcfef7839766d8e"}, "dotenvy": {:hex, :dotenvy, "1.1.1", "00e318f3c51de9fafc4b48598447e386f19204dc18ca69886905bb8f8b08b667", [:mix], [], "hexpm", "c8269471b5701e9e56dc86509c1199ded2b33dce088c3471afcfef7839766d8e"},
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
"ecto_ch": {:hex, :ecto_ch, "0.8.6", "f31b507e86690c003f46e75d6e742e6b5d8ce34b6b10a86604b1c3aa785e0b56", [:mix], [{:ch, "~> 0.5.0 or ~> 0.6.0 or ~> 0.7.0", [hex: :ch, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "6ca9f1cf9680452b1925c6a3a7b5e3d8b12e38ee134b03c6a45a8b26434fad97"},
"ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"}, "ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},

View File

@@ -0,0 +1,4 @@
[
import_deps: [:ecto_ch],
inputs: ["*.exs"]
]

View File

@@ -0,0 +1,49 @@
defmodule Mixer.ClickhouseRepo.Migrations.CreatePostEvents do
use Ecto.Migration
@doc """
Creates the `post_events` table using a MergeTree engine.
Key design decisions:
* `LowCardinality(String)` for `event_type` — the cardinality is tiny
(510 values), so ClickHouse can store it as a dictionary, giving both
compression and faster filtering.
* `Nullable(UUID)` / `Nullable(String)` for optional columns — ClickHouse
handles NULLs differently from PostgreSQL; we make the nullable fields
explicit so the schema is unambiguous.
* `ORDER BY (occurred_at, event_type, tweet_id)` — optimises the two most
common query patterns:
1. Time-range scans (`WHERE occurred_at >= now() - interval 24 HOUR`)
2. Per-tweet aggregations (`WHERE tweet_id = ?`)
* `PARTITION BY toYYYYMM(occurred_at)` — monthly partitions make it cheap
to drop old data with `ALTER TABLE … DROP PARTITION`.
* `TTL occurred_at + INTERVAL 1 YEAR DELETE` — automatically reclaim disk
space after two years. Adjust as required.
"""
def up do
execute("""
CREATE TABLE IF NOT EXISTS post_events
(
event_type LowCardinality(String),
tweet_id UUID,
user_id Nullable(UUID),
occurred_at DateTime,
ip_address Nullable(String)
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(occurred_at)
ORDER BY (occurred_at, event_type, tweet_id)
TTL occurred_at + INTERVAL 1 YEAR DELETE
SETTINGS index_granularity = 8192
""")
end
def down do
execute("DROP TABLE IF EXISTS post_events")
end
end

11
priv/clickhouse/seeds.exs Normal file
View File

@@ -0,0 +1,11 @@
# Script for populating the database. You can run it as:
#
# mix run priv/clickhouse/seeds.exs
#
# Inside the script, you can read and write to any of your
# repositories directly:
#
# Mixer.ClickhouseRepo.insert!(%Mixer.SomeSchema{})
#
# We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong.

View File

@@ -0,0 +1,29 @@
defmodule Mixer.Repo.Migrations.AddUserProfileFields do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
alter table(:users) do
add :username, :text
add :display_name, :text
add :avatar_url, :text
end
create unique_index(:users, [:username], name: "users_unique_username_index")
end
def down do
drop_if_exists unique_index(:users, [:username], name: "users_unique_username_index")
alter table(:users) do
remove :avatar_url
remove :display_name
remove :username
end
end
end

View File

@@ -0,0 +1,133 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "email",
"type": "citext"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "hashed_password",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "confirmed_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "username",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "display_name",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "avatar_url",
"type": "text"
}
],
"base_filter": null,
"check_constraints": [],
"create_table_options": null,
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "E57BFA1141A2F4D237E6B3C8FE4BAD93772015179B56AEC9FA1F762C4FF5B6B8",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "users_unique_email_index",
"keys": [
{
"type": "atom",
"value": "email"
}
],
"name": "unique_email",
"nils_distinct?": true,
"where": null
},
{
"all_tenants?": false,
"base_filter": null,
"index_name": "users_unique_username_index",
"keys": [
{
"type": "atom",
"value": "username"
}
],
"name": "unique_username",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mixer.Repo",
"schema": null,
"table": "users"
}

View File

@@ -93,11 +93,15 @@ defmodule Mixer.Posts.TweetLikeTest do
end end
defp user_fixture(email) do defp user_fixture(email) do
username =
email |> String.split("@") |> List.first() |> String.replace(~r/[^a-zA-Z0-9_]/, "_")
User User
|> Ash.Changeset.for_create(:register_with_password, %{ |> Ash.Changeset.for_create(:register_with_password, %{
email: email, email: email,
password: "password1234", password: "password1234",
password_confirmation: "password1234" password_confirmation: "password1234",
username: username
}) })
|> Ash.create!() |> Ash.create!()
end end

View File

@@ -9,7 +9,8 @@ defmodule MixerWeb.PageControllerTest do
%{ %{
email: "test@example.com", email: "test@example.com",
password: "Password1!", password: "Password1!",
password_confirmation: "Password1!" password_confirmation: "Password1!",
username: "testuser"
}, },
authorize?: false authorize?: false
) )