Compare commits

..

28 Commits

Author SHA1 Message Date
d194834110 claude fix for making the avatars properly get re-fetched if they are newer than the old avatars 2026-04-09 17:44:13 -04:00
2130d85be5 claude generated code for adding tweet viewership to users pages 2026-04-09 17:10:05 -04:00
f37d554399 fixed auth userflow 2026-04-09 15:24:26 -04:00
2d5914c970 some not working changes trying to fix user login to include a username 2026-04-08 04:34:16 -04:00
31a8f03ab2 gemini fixed it but its ux does not work and lowkey idc 2026-04-08 02:27:52 -04:00
90d7eab7d0 some ai generated code from claude that does not work 2026-04-08 02:03:43 -04:00
3c9910a723 Working metrics for all forms of interactions and updated .env.example 2026-04-07 00:09:10 -04:00
76a8acc731 Adjusted to properly type each of the database interactions 2026-04-06 23:31:17 -04:00
a33ec14c5f Integrating clickhouse for metrics. 2026-04-06 23:05:04 -04:00
109ef398ee paginating comments and letting tweet authors delete comments on their post 2026-04-06 14:15:36 -04:00
faa96d88f5 added comments to tweets 2026-04-06 14:11:10 -04:00
6927f6eb9b Login with password now requires email to be confirmed 2026-04-06 13:18:00 -04:00
cc6586587f Added some meta tags for "SEO" (fake ass concept for this) but hopefully works for embeds 2026-04-05 15:19:47 -04:00
315b108fa1 Added page titles and proper favicon 2026-04-05 15:15:07 -04:00
4ec41ad4b3 Added /profile to see your profile 2026-04-05 14:50:50 -04:00
4b36131183 Added following page to see posts from yourself and people you follow 2026-04-05 14:41:29 -04:00
8077e570f4 changed feed page to paginate requests 2026-04-05 14:32:51 -04:00
33c83e188e fixed mobile ui and ux 2026-04-04 13:02:10 -04:00
193ff815a1 Mobile nav and drafting setup 2026-04-04 12:38:40 -04:00
1ed136e637 fixed tweet time display to show when the tweet was actually posted 2026-04-04 12:29:54 -04:00
bd0f5d52c2 fixing auth login flow and making custom login flow 2026-04-03 19:54:18 -04:00
874fec835d some reformatting and adjusting so logged in users get moved directly to their feed 2026-04-03 19:40:17 -04:00
a926733f1b adding this to prevent a warning on startup of the app 2026-04-03 19:12:52 -04:00
a70ea18e56 added .env support for a systemd EnvironmentFile with an example and updated email sender 2026-04-03 17:00:38 -04:00
abe10922eb claude fixed up the ui 2026-04-02 21:46:15 -04:00
9c131b98a6 Working follow and unfollow interactions for users 2026-04-02 21:41:27 -04:00
f82bc223bb added right click context menu and did a static deployment test 2026-04-02 03:34:56 -04:00
580265bc51 Added users page and user viewing 2026-04-02 03:28:09 -04:00
66 changed files with 5012 additions and 249 deletions

40
.env.example Normal file
View File

@@ -0,0 +1,40 @@
# Phoenix / App
PHX_HOST=mixer.example.com
PHX_SERVER=true
PORT=4000
SECRET_KEY_BASE=REPLACE_WITH_64_CHAR_SECRET # generate with: mix phx.gen.secret
# Database
DATABASE_URL=ecto://USER:PASSWORD@HOST/DATABASE
ECTO_IPV6=false
POOL_SIZE=10
# Clustering (leave blank if not using DNS-based clustering)
DNS_CLUSTER_QUERY=
# Auth
TOKEN_SIGNING_SECRET=REPLACE_WITH_SECRET
# S3 / Object Storage
S3_ACCESS_KEY_ID=your-access-key-id
S3_SECRET_ACCESS_KEY=your-secret-access-key
S3_HOST=s3.amazonaws.com
S3_BUCKET=your-bucket-name
S3_ASSET_HOST=https://your-bucket.s3.amazonaws.com
S3_SCHEME=https://
S3_PORT=80
S3_VIRTUAL_HOST=false
# Email (Brevo)
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

3
.gitignore vendored
View File

@@ -1,3 +1,6 @@
# .env
.env
# The directory Mix will write compiled artifacts to.
/_build/

View File

@@ -152,15 +152,6 @@ html, body {
margin: 0 auto;
}
@media (max-width: 960px) {
.mx-root { grid-template-columns: 64px 1fr; }
.mx-rightbar { display: none; }
}
@media (max-width: 640px) {
.mx-root { grid-template-columns: 1fr; }
.mx-sidebar { display: none; }
}
/* ── Sidebar ── */
.mx-sidebar {
position: sticky;
@@ -312,6 +303,12 @@ html, body {
user-select: none;
}
.mx-tweet-avatar--lg {
width: 56px;
height: 56px;
font-size: 1.25rem;
}
.mx-compose-body { flex: 1; }
.mx-compose-textarea, .mx-edit-textarea {
@@ -466,6 +463,30 @@ html, body {
.mx-action-delete:hover { color: var(--mx-red); background: color-mix(in oklch, var(--mx-red) 10%, transparent); }
.mx-action-confirm { color: var(--mx-red) !important; background: color-mix(in oklch, var(--mx-red) 15%, transparent) !important; }
.mx-follow-btn {
padding: 0.25rem 0.875rem;
border-radius: 9999px;
border: 1.5px solid var(--mx-border2);
background: none;
color: var(--mx-fg);
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.mx-follow-btn:hover:not(:disabled) { background: var(--mx-surface2); }
.mx-follow-btn--following {
background: var(--mx-surface);
color: var(--mx-muted);
}
.mx-follow-btn--following:hover:not(:disabled) {
background: color-mix(in oklch, var(--mx-red) 10%, transparent);
border-color: var(--mx-red);
color: var(--mx-red);
}
.mx-follow-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.mx-tweet-text {
font-size: 1rem;
line-height: 1.6;
@@ -675,6 +696,104 @@ html, body {
margin-bottom: 1rem;
}
/* ── Comment button on tweet cards ── */
.mx-comment-btn {
text-decoration: none;
margin-left: 0.5rem;
color: var(--mx-fg2);
transition: color 0.15s, border-color 0.15s, background 0.15s, transform 0.15s;
}
.mx-comment-btn:hover {
color: var(--mx-accent);
border-color: color-mix(in oklch, var(--mx-accent) 35%, transparent);
background: color-mix(in oklch, var(--mx-accent) 10%, transparent);
transform: translateY(-1px);
}
/* Non-interactive reply count badge in detail view */
.mx-comment-count-badge {
margin-left: 0.5rem;
cursor: default;
pointer-events: none;
color: var(--mx-fg2);
}
/* ── Comments section (below tweet detail) ── */
.mx-comments-section {
border-top: 1px solid var(--mx-border);
margin-top: 0.5rem;
padding: 0 1.5rem 1.5rem;
}
.mx-comments-divider {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 0 0.75rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mx-fg2);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.mx-comments-divider::after {
content: "";
flex: 1;
height: 1px;
background: var(--mx-border);
}
.mx-comments-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.75rem;
}
/* Comment card — slightly indented, more compact */
.mx-comment {
padding: 0.75rem 1rem;
border-radius: var(--mx-radius-sm);
}
/* Small avatar variant for comments and compose-comment */
.mx-tweet-avatar--sm {
width: 28px;
height: 28px;
min-width: 28px;
font-size: 0.75rem;
}
/* Compact compose box for replies */
.mx-compose--comment {
padding: 0.75rem 0;
border-bottom: 1px solid var(--mx-border);
margin-bottom: 0.25rem;
}
.mx-compose--comment .mx-compose-avatar--sm { align-self: flex-start; }
.mx-compose-textarea--sm {
min-height: 2.5rem;
padding: 0.4rem 0.6rem;
font-size: 0.9375rem;
}
.mx-btn-post--sm {
padding: 0.35rem 0.875rem;
font-size: 0.8125rem;
}
/* Small empty state */
.mx-empty--sm {
padding: 1.5rem 0.5rem;
}
/* Small sign-in CTA */
.mx-signin-cta--sm {
padding: 0.75rem 0;
font-size: 0.875rem;
color: var(--mx-muted);
}
/* ── Clickable media thumb (used in detail view) ── */
.mx-media-thumb {
background: none;
@@ -734,3 +853,374 @@ html, body {
border-radius: var(--mx-radius-sm);
display: block;
}
/* ── Context Menu ── */
.mx-context-menu {
position: fixed;
z-index: 300;
min-width: 160px;
background: var(--mx-surface2);
border: 1px solid var(--mx-border2);
border-radius: var(--mx-radius-sm);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35), 0 2px 6px rgba(0, 0, 0, 0.2);
overflow: hidden;
padding: 4px 0;
animation: mx-ctx-in 0.1s ease;
}
@keyframes mx-ctx-in {
from { opacity: 0; transform: scale(0.96) translateY(-4px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.mx-context-menu-item {
display: flex;
align-items: center;
width: 100%;
padding: 0.45rem 0.875rem;
background: none;
border: none;
color: var(--mx-fg);
font-size: 0.875rem;
font-family: inherit;
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.mx-context-menu-item:hover {
background: color-mix(in oklch, var(--mx-accent) 14%, transparent);
}
.mx-context-menu-separator {
height: 1px;
background: var(--mx-border);
margin: 4px 0;
}
/* ─────────────────────────────────────────────────────────────────────────────
Mobile bottom navigation bar
Only shown at ≤640 px (sidebar is hidden at that breakpoint).
───────────────────────────────────────────────────────────────────────────── */
.mx-mobile-nav {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 50;
height: 60px;
align-items: center;
justify-content: space-around;
background: color-mix(in oklch, var(--mx-bg) 92%, transparent);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-top: 1px solid var(--mx-border);
/* respect iPhone home indicator */
padding-bottom: env(safe-area-inset-bottom, 0px);
}
@media (max-width: 960px) {
.mx-mobile-nav { display: flex; }
}
.mx-mobile-nav-item {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
color: var(--mx-muted);
text-decoration: none;
font-size: 0.65rem;
font-weight: 500;
padding: 0.25rem 0;
transition: color 0.15s;
-webkit-tap-highlight-color: transparent;
}
.mx-mobile-nav-item--active { color: var(--mx-accent); }
.mx-mobile-nav-item svg {
flex-shrink: 0;
}
/* Centred compose button — raised pill */
.mx-mobile-nav-compose {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--mx-accent);
color: #fff;
border: none;
cursor: pointer;
box-shadow: 0 4px 16px color-mix(in oklch, var(--mx-accent) 55%, transparent);
transition: background 0.15s, transform 0.12s, box-shadow 0.15s;
-webkit-tap-highlight-color: transparent;
}
.mx-mobile-nav-compose:hover {
background: var(--mx-accent2);
box-shadow: 0 6px 20px color-mix(in oklch, var(--mx-accent) 65%, transparent);
transform: scale(1.06);
}
.mx-mobile-nav-compose:active { transform: scale(0.94); }
/* ─────────────────────────────────────────────────────────────────────────────
Mobile compose overlay (full-screen drafting page)
Hidden on desktop — only the mobile nav can trigger it.
───────────────────────────────────────────────────────────────────────────── */
@keyframes mx-overlay-in {
from { opacity: 0; transform: translateY(28px); }
to { opacity: 1; transform: translateY(0); }
}
.mx-compose-overlay {
display: none;
}
@media (max-width: 960px) {
.mx-compose-overlay {
display: flex;
flex-direction: column;
position: fixed;
inset: 0;
z-index: 100;
background: var(--mx-bg);
animation: mx-overlay-in 0.22s cubic-bezier(0.34, 1.1, 0.64, 1);
}
}
.mx-compose-overlay-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--mx-border);
background: color-mix(in oklch, var(--mx-bg) 85%, transparent);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
position: sticky;
top: 0;
z-index: 1;
}
.mx-compose-overlay-title {
font-family: 'Instrument Serif', Georgia, serif;
font-size: 1.125rem;
font-style: italic;
letter-spacing: -0.01em;
color: var(--mx-fg);
}
.mx-compose-overlay-cancel {
background: none;
border: none;
color: var(--mx-fg2);
font-size: 0.9rem;
font-family: inherit;
cursor: pointer;
padding: 0.25rem 0;
min-width: 60px;
transition: color 0.15s;
}
.mx-compose-overlay-cancel:hover { color: var(--mx-fg); }
.mx-compose-overlay-body {
flex: 1;
overflow-y: auto;
padding: 1.25rem;
}
/* ───────────────────────────────────────────────────────────────────────────────
Responsive layout overrides
IMPORTANT: these rules must live AFTER all component base rules so that
the cascade works correctly (later rule of equal specificity wins).
─────────────────────────────────────────────────────────────────────────────── */
/* Tablet + mobile (≤ 960 px): single column, no side panels, bottom nav */
@media (max-width: 960px) {
.mx-root { grid-template-columns: 1fr; }
.mx-sidebar { display: none; }
.mx-rightbar { display: none; }
/* room for fixed bottom nav */
.mx-main { padding-bottom: 72px; }
/* hide inline compose — the overlay FAB handles it */
.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 */
@media (max-width: 640px) {
.mx-feed { padding: 0.625rem; gap: 0.5rem; }
.mx-tweet { padding: 0.875rem; }
.mx-tweet-handle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 180px;
}
.mx-header { padding: 0.75rem 1rem; }
.mx-detail { padding: 0.875rem 1rem; }
/* 5-item nav: slightly smaller labels so nothing wraps */
.mx-mobile-nav-item { font-size: 0.6rem; }
}

View File

@@ -2,7 +2,7 @@
// Do not edit this file manually
import type { AshRpcError, ConditionalPaginatedResultMixed, InferResult, SortString, UUID, UnifiedFieldSelection, ValidationResult, mediaFilterInput, mediaResourceSchema, mediaSortField, tweetsFilterInput, tweetsResourceSchema, tweetsSortField } from "./ash_types";
import type { AshRpcError, ConditionalPaginatedResultMixed, InferResult, SortString, UUID, UnifiedFieldSelection, ValidationResult, followsFilterInput, followsResourceSchema, followsSortField, mediaFilterInput, mediaResourceSchema, mediaSortField, tweetsFilterInput, tweetsResourceSchema, tweetsSortField, usersFilterInput, usersResourceSchema, usersSortField } from "./ash_types";
export type * from "./ash_types";
// Helper Functions
@@ -201,6 +201,423 @@ export async function executeValidationRpcRequest<T>(
export type FollowUserInput = {
followingId: UUID;
};
export type FollowUserFields = UnifiedFieldSelection<followsResourceSchema>[];
export type InferFollowUserResult<
Fields extends FollowUserFields | undefined,
> = InferResult<followsResourceSchema, Fields>;
export type FollowUserResult<Fields extends FollowUserFields | undefined = undefined> = | { success: true; data: InferFollowUserResult<Fields>; }
| { success: false; errors: AshRpcError[]; }
;
/**
* Create a new Follow
*
* @ashActionType :create
*/
export async function followUser<Fields extends FollowUserFields | undefined = undefined>(
config: {
tenant?: string;
input: FollowUserInput;
fields?: Fields;
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}
): Promise<FollowUserResult<Fields extends undefined ? [] : Fields>> {
const payload = {
action: "follow_user",
...(config.tenant !== undefined && { tenant: config.tenant }),
input: config.input,
...(config.fields !== undefined && { fields: config.fields })
};
return executeActionRpcRequest<FollowUserResult<Fields extends undefined ? [] : Fields>>(
payload,
config
);
}
/**
* Validate: Create a new Follow
*
* @ashActionType :create
* @validation true
*/
export async function validateFollowUser(
config: {
tenant?: string;
input: FollowUserInput;
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}
): Promise<ValidationResult> {
const payload = {
action: "follow_user",
...(config.tenant !== undefined && { tenant: config.tenant }),
input: config.input
};
return executeValidationRpcRequest<ValidationResult>(
payload,
config
);
}
export type ReadFollowFields = UnifiedFieldSelection<followsResourceSchema>[];
export type InferReadFollowResult<
Fields extends ReadFollowFields | undefined,
Page extends ReadFollowConfig["page"] = undefined
> = ConditionalPaginatedResultMixed<Page, Array<InferResult<followsResourceSchema, Fields>>, {
results: Array<InferResult<followsResourceSchema, Fields>>;
hasMore: boolean;
limit: number;
offset: number;
count?: number | null;
type: "offset";
}, {
results: Array<InferResult<followsResourceSchema, Fields>>;
hasMore: boolean;
limit: number;
after: string | null;
before: string | null;
previousPage: string;
nextPage: string;
count?: number | null;
type: "keyset";
}>;
export type ReadFollowConfig = {
tenant?: string;
fields: ReadFollowFields;
filter?: followsFilterInput;
sort?: SortString<followsSortField> | SortString<followsSortField>[];
page?: (
{
limit?: number;
offset?: number;
count?: boolean;
} | {
limit?: number;
after?: string;
before?: string;
}
);
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
};
export type ReadFollowResult<Fields extends ReadFollowFields, Page extends ReadFollowConfig["page"] = undefined> = | { success: true; data: InferReadFollowResult<Fields, Page>; }
| { success: false; errors: AshRpcError[]; }
;
/**
* Read Follow records
*
* @ashActionType :read
*/
export async function readFollow<Fields extends ReadFollowFields, Config extends ReadFollowConfig = ReadFollowConfig>(
config: Config & { fields: Fields }
): Promise<ReadFollowResult<Fields, Config["page"]>> {
const payload = {
action: "read_follow",
...(config.tenant !== undefined && { tenant: config.tenant }),
...(config.fields !== undefined && { fields: config.fields }),
...(config.filter && { filter: config.filter }),
...(config.sort && { sort: Array.isArray(config.sort) ? config.sort.join(",") : config.sort }),
...(config.page && { page: config.page })
};
return executeActionRpcRequest<ReadFollowResult<Fields, Config["page"]>>(
payload,
config
);
}
/**
* Validate: Read Follow records
*
* @ashActionType :read
* @validation true
*/
export async function validateReadFollow(
config: {
tenant?: string;
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}
): Promise<ValidationResult> {
const payload = {
action: "read_follow",
...(config.tenant !== undefined && { tenant: config.tenant })
};
return executeValidationRpcRequest<ValidationResult>(
payload,
config
);
}
export type UnfollowUserInput = {
followingId: UUID;
};
export type InferUnfollowUserResult = {};
export type UnfollowUserResult = | { success: true; data: InferUnfollowUserResult; }
| { success: false; errors: AshRpcError[]; }
;
/**
* Execute generic action on Follow
*
* @ashActionType :action
*/
export async function unfollowUser(
config: {
tenant?: string;
input: UnfollowUserInput;
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}
): Promise<UnfollowUserResult> {
const payload = {
action: "unfollow_user",
...(config.tenant !== undefined && { tenant: config.tenant }),
input: config.input
};
return executeActionRpcRequest<UnfollowUserResult>(
payload,
config
);
}
/**
* Validate: Execute generic action on Follow
*
* @ashActionType :action
* @validation true
*/
export async function validateUnfollowUser(
config: {
tenant?: string;
input: UnfollowUserInput;
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}
): Promise<ValidationResult> {
const payload = {
action: "unfollow_user",
...(config.tenant !== undefined && { tenant: config.tenant }),
input: config.input
};
return executeValidationRpcRequest<ValidationResult>(
payload,
config
);
}
export type ReadUserFields = UnifiedFieldSelection<usersResourceSchema>[];
export type InferReadUserResult<
Fields extends ReadUserFields | undefined,
Page extends ReadUserConfig["page"] = undefined
> = ConditionalPaginatedResultMixed<Page, Array<InferResult<usersResourceSchema, Fields>>, {
results: Array<InferResult<usersResourceSchema, Fields>>;
hasMore: boolean;
limit: number;
offset: number;
count?: number | null;
type: "offset";
}, {
results: Array<InferResult<usersResourceSchema, Fields>>;
hasMore: boolean;
limit: number;
after: string | null;
before: string | null;
previousPage: string;
nextPage: string;
count?: number | null;
type: "keyset";
}>;
export type ReadUserConfig = {
tenant?: string;
fields: ReadUserFields;
filter?: usersFilterInput;
sort?: SortString<usersSortField> | SortString<usersSortField>[];
page?: (
{
limit?: number;
offset?: number;
count?: boolean;
} | {
limit?: number;
after?: string;
before?: string;
}
);
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
};
export type ReadUserResult<Fields extends ReadUserFields, Page extends ReadUserConfig["page"] = undefined> = | { success: true; data: InferReadUserResult<Fields, Page>; }
| { success: false; errors: AshRpcError[]; }
;
/**
* Read User records
*
* @ashActionType :read
*/
export async function readUser<Fields extends ReadUserFields, Config extends ReadUserConfig = ReadUserConfig>(
config: Config & { fields: Fields }
): Promise<ReadUserResult<Fields, Config["page"]>> {
const payload = {
action: "read_user",
...(config.tenant !== undefined && { tenant: config.tenant }),
...(config.fields !== undefined && { fields: config.fields }),
...(config.filter && { filter: config.filter }),
...(config.sort && { sort: Array.isArray(config.sort) ? config.sort.join(",") : config.sort }),
...(config.page && { page: config.page })
};
return executeActionRpcRequest<ReadUserResult<Fields, Config["page"]>>(
payload,
config
);
}
/**
* Validate: Read User records
*
* @ashActionType :read
* @validation true
*/
export async function validateReadUser(
config: {
tenant?: string;
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}
): Promise<ValidationResult> {
const payload = {
action: "read_user",
...(config.tenant !== undefined && { tenant: config.tenant })
};
return executeValidationRpcRequest<ValidationResult>(
payload,
config
);
}
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>[];
@@ -304,6 +721,7 @@ export async function validateReadMedia(
export type CreateTweetInput = {
content: string;
parentTweetId?: UUID | null;
mediaId?: UUID;
};
@@ -503,6 +921,73 @@ export async function validateLikeTweet(
}
export type ReadFollowingFeedFields = UnifiedFieldSelection<tweetsResourceSchema>[];
export type InferReadFollowingFeedResult<
Fields extends ReadFollowingFeedFields,
> = Array<InferResult<tweetsResourceSchema, Fields>>;
export type ReadFollowingFeedResult<Fields extends ReadFollowingFeedFields> = | { success: true; data: InferReadFollowingFeedResult<Fields>; }
| { success: false; errors: AshRpcError[]; }
;
/**
* Read Tweet records
*
* @ashActionType :read
*/
export async function readFollowingFeed<Fields extends ReadFollowingFeedFields>(
config: {
tenant?: string;
fields: Fields;
filter?: tweetsFilterInput;
sort?: SortString<tweetsSortField> | SortString<tweetsSortField>[];
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}
): Promise<ReadFollowingFeedResult<Fields>> {
const payload = {
action: "read_following_feed",
...(config.tenant !== undefined && { tenant: config.tenant }),
...(config.fields !== undefined && { fields: config.fields }),
...(config.filter && { filter: config.filter }),
...(config.sort && { sort: Array.isArray(config.sort) ? config.sort.join(",") : config.sort })
};
return executeActionRpcRequest<ReadFollowingFeedResult<Fields>>(
payload,
config
);
}
/**
* Validate: Read Tweet records
*
* @ashActionType :read
* @validation true
*/
export async function validateReadFollowingFeed(
config: {
tenant?: string;
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}
): Promise<ValidationResult> {
const payload = {
action: "read_following_feed",
...(config.tenant !== undefined && { tenant: config.tenant })
};
return executeValidationRpcRequest<ValidationResult>(
payload,
config
);
}
export type ReadTweetFields = UnifiedFieldSelection<tweetsResourceSchema>[];

View File

@@ -6,6 +6,50 @@
export type UUID = string;
export type UtcDateTimeUsec = string;
// follows Schema
export type followsResourceSchema = {
__type: "Resource";
__primitiveFields: "id";
id: UUID;
};
export type followsAttributesOnlySchema = {
__type: "Resource";
__primitiveFields: "id";
id: UUID;
};
// users Schema
export type usersResourceSchema = {
__type: "Resource";
__primitiveFields: "id" | "email" | "username" | "displayName" | "avatarUrl" | "followerCount" | "followingCount" | "amIFollowing" | "myFollowId";
id: UUID;
email: string;
username: string | null;
displayName: string | null;
avatarUrl: string | null;
followerCount: number;
followingCount: number;
amIFollowing: boolean;
myFollowId: UUID;
};
export type usersAttributesOnlySchema = {
__type: "Resource";
__primitiveFields: "id" | "email" | "username" | "displayName" | "avatarUrl";
id: UUID;
email: string;
username: string | null;
displayName: string | null;
avatarUrl: string | null;
};
// media Schema
export type mediaResourceSchema = {
__type: "Resource";
@@ -14,6 +58,7 @@ export type mediaResourceSchema = {
s3Key: string;
userId: UUID;
tweetId: UUID | null;
user: { __type: "Relationship"; __resource: usersResourceSchema; };
tweet: { __type: "Relationship"; __resource: tweetsResourceSchema | null; };
};
@@ -32,15 +77,23 @@ export type mediaAttributesOnlySchema = {
// tweets Schema
export type tweetsResourceSchema = {
__type: "Resource";
__primitiveFields: "id" | "content" | "likes" | "userId" | "insertedAt" | "state" | "likedByMe" | "userEmail";
__primitiveFields: "id" | "content" | "likes" | "userId" | "insertedAt" | "state" | "parentTweetId" | "commentCount" | "likedByMe" | "userEmail" | "userUsername" | "userDisplayName" | "userAvatarUrl";
id: UUID;
content: string;
likes: number;
userId: UUID;
insertedAt: UtcDateTimeUsec;
state: "posted" | "drafted";
parentTweetId: UUID | null;
commentCount: number;
likedByMe: boolean;
userEmail: string | null;
userUsername: string | null;
userDisplayName: string | null;
userAvatarUrl: string | null;
user: { __type: "Relationship"; __resource: usersResourceSchema; };
parentTweet: { __type: "Relationship"; __resource: tweetsResourceSchema | null; };
comments: { __type: "Relationship"; __array: true; __resource: tweetsResourceSchema; };
media: { __type: "Relationship"; __array: true; __resource: mediaResourceSchema; };
};
@@ -48,16 +101,106 @@ export type tweetsResourceSchema = {
export type tweetsAttributesOnlySchema = {
__type: "Resource";
__primitiveFields: "id" | "content" | "likes" | "userId" | "insertedAt" | "state";
__primitiveFields: "id" | "content" | "likes" | "userId" | "insertedAt" | "state" | "parentTweetId";
id: UUID;
content: string;
likes: number;
userId: UUID;
insertedAt: UtcDateTimeUsec;
state: "posted" | "drafted";
parentTweetId: UUID | null;
};
export type followsFilterInput = {
and?: Array<followsFilterInput>;
or?: Array<followsFilterInput>;
not?: Array<followsFilterInput>;
id?: {
eq?: UUID;
notEq?: UUID;
in?: Array<UUID>;
};
};
export type usersFilterInput = {
and?: Array<usersFilterInput>;
or?: Array<usersFilterInput>;
not?: Array<usersFilterInput>;
id?: {
eq?: UUID;
notEq?: UUID;
in?: Array<UUID>;
};
email?: {
eq?: string;
notEq?: 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?: {
eq?: number;
notEq?: number;
greaterThan?: number;
greaterThanOrEqual?: number;
lessThan?: number;
lessThanOrEqual?: number;
in?: Array<number>;
isNil?: boolean;
};
followingCount?: {
eq?: number;
notEq?: number;
greaterThan?: number;
greaterThanOrEqual?: number;
lessThan?: number;
lessThanOrEqual?: number;
in?: Array<number>;
isNil?: boolean;
};
amIFollowing?: {
eq?: boolean;
notEq?: boolean;
isNil?: boolean;
};
myFollowId?: {
eq?: UUID;
notEq?: UUID;
in?: Array<UUID>;
isNil?: boolean;
};
};
export type mediaFilterInput = {
and?: Array<mediaFilterInput>;
or?: Array<mediaFilterInput>;
@@ -89,6 +232,8 @@ export type mediaFilterInput = {
};
user?: usersFilterInput;
tweet?: tweetsFilterInput;
};
@@ -141,6 +286,13 @@ export type tweetsFilterInput = {
in?: Array<"posted" | "drafted">;
};
parentTweetId?: {
eq?: UUID;
notEq?: UUID;
in?: Array<UUID>;
isNil?: boolean;
};
userEmail?: {
eq?: string;
notEq?: string;
@@ -148,28 +300,78 @@ export type tweetsFilterInput = {
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?: {
eq?: number;
notEq?: number;
greaterThan?: number;
greaterThanOrEqual?: number;
lessThan?: number;
lessThanOrEqual?: number;
in?: Array<number>;
isNil?: boolean;
};
likedByMe?: {
eq?: boolean;
notEq?: boolean;
isNil?: boolean;
};
user?: usersFilterInput;
parentTweet?: tweetsFilterInput;
comments?: tweetsFilterInput;
media?: mediaFilterInput;
};
export const followsFilterFields = ["id"] as const;
export type followsFilterField = (typeof followsFilterFields)[number];
export const usersFilterFields = ["id", "email", "username", "displayName", "avatarUrl", "followerCount", "followingCount", "amIFollowing", "myFollowId"] as const;
export type usersFilterField = (typeof usersFilterFields)[number];
export const mediaFilterFields = ["id", "s3Key", "userId", "tweetId", "user", "tweet"] as const;
export type mediaFilterField = (typeof mediaFilterFields)[number];
export const tweetsFilterFields = ["id", "content", "likes", "userId", "insertedAt", "state", "userEmail", "likedByMe", "user", "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 const followsSortFields = ["id"] as const;
export type followsSortField = (typeof followsSortFields)[number];
export const usersSortFields = ["id", "email", "username", "displayName", "avatarUrl", "followerCount", "followingCount", "amIFollowing", "myFollowId"] as const;
export type usersSortField = (typeof usersSortFields)[number];
export const mediaSortFields = ["id", "s3Key", "userId", "tweetId"] as const;
export type mediaSortField = (typeof mediaSortFields)[number];
export const tweetsSortFields = ["id", "content", "likes", "userId", "insertedAt", "state", "userEmail", "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];

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,29 @@ export interface UploadError {
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(
file: File,
csrfToken: string

View File

@@ -94,7 +94,7 @@ config :spark,
]
config :mixer,
ecto_repos: [Mixer.Repo],
ecto_repos: [Mixer.Repo, Mixer.ClickhouseRepo],
generators: [timestamp_type: :utc_datetime],
ash_domains: [Mixer.Accounts, Mixer.Posts],
ash_authentication: [return_error_on_invalid_magic_link_token?: true]
@@ -126,7 +126,17 @@ config :esbuild,
args:
~w(js/index.tsx js/app.js --bundle --target=es2022 --outdir=../priv/static/assets --external:/fonts/* --external:/images/* --alias:@=. --splitting --format=esm),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => Enum.join([Path.expand("../deps", __DIR__), Path.expand(Mix.Project.build_path()), Path.expand("../_build/dev", __DIR__)], ":")}
env: %{
"NODE_PATH" =>
Enum.join(
[
Path.expand("../deps", __DIR__),
Path.expand(Mix.Project.build_path()),
Path.expand("../_build/dev", __DIR__)
],
":"
)
}
]
# Configure tailwind (the version is required)
@@ -148,6 +158,11 @@ config :logger, :default_formatter,
# Use Jason for JSON parsing in Phoenix
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
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"

View File

@@ -106,3 +106,12 @@ config :ex_aws, :s3,
config :waffle,
bucket: "mixer-bucket",
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

@@ -21,7 +21,7 @@ config :mixer, MixerWeb.Endpoint,
]
# Configure Swoosh API Client
config :swoosh, api_client: Swoosh.ApiClient.Req
config :swoosh, api_client: Swoosh.ApiClient.Hackney
# Disable Swoosh Local Memory Storage
config :swoosh, local: false

View File

@@ -22,6 +22,11 @@ end
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
database_url =
System.get_env("DATABASE_URL") ||
@@ -40,6 +45,19 @@ if config_env() == :prod do
# pool_count: 4,
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.
# 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
@@ -52,7 +70,7 @@ if config_env() == :prod do
You can generate one by calling: mix phx.gen.secret
"""
host = System.get_env("PHX_HOST") || "example.com"
host = System.get_env("PHX_HOST") || "mixer.jimweaver.com"
config :mixer, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
@@ -97,6 +115,12 @@ if config_env() == :prod do
System.get_env("S3_ASSET_HOST") ||
raise("Missing environment variable `S3_ASSET_HOST`!")
config :mixer, Mixer.Mailer,
adapter: Swoosh.Adapters.Brevo,
api_key:
System.get_env("BREVO_API_KEY") ||
raise("Missing environment variable `BREVO_API_KEY`!")
# ## SSL Support
#
# To get SSL working, you will need to add the `https` key

View File

@@ -42,3 +42,12 @@ config :phoenix_live_view,
# Sort query params output of verified routes for robust url comparisons
config :phoenix,
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

@@ -1,5 +1,18 @@
defmodule Mixer.Accounts do
use Ash.Domain, otp_app: :mixer, extensions: [AshAdmin.Domain]
use Ash.Domain, otp_app: :mixer, extensions: [AshTypescript.Rpc, AshAdmin.Domain]
typescript_rpc do
resource Mixer.Accounts.User do
rpc_action :read_user, :read
rpc_action :update_profile, :update_profile
end
resource Mixer.Accounts.Follow do
rpc_action :read_follow, :read
rpc_action :follow_user, :follow
rpc_action :unfollow_user, :unfollow
end
end
admin do
show? true
@@ -9,5 +22,7 @@ defmodule Mixer.Accounts do
resource Mixer.Accounts.Token
resource Mixer.Accounts.User
resource Mixer.Accounts.ApiKey
resource Mixer.Accounts.Follow
end
end

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

@@ -0,0 +1,104 @@
defmodule Mixer.Accounts.Follow do
require Ash.Query
use Ash.Resource,
domain: Mixer.Accounts,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer],
extensions: [AshTypescript.Resource]
postgres do
table "follows"
repo Mixer.Repo
references do
reference :follower, on_delete: :delete
reference :following, on_delete: :delete
end
end
typescript do
type_name "follows"
end
actions do
defaults [:read, :destroy]
create :follow do
primary? true
upsert? true
upsert_identity :unique_follow
accept [:following_id]
change relate_actor(:follower)
validate fn changeset, _context ->
follower_id = Ash.Changeset.get_attribute(changeset, :follower_id)
following_id = Ash.Changeset.get_attribute(changeset, :following_id)
if follower_id == following_id do
{:error, field: :following_id, message: "You cannot follow yourself"}
else
:ok
end
end
end
action :unfollow do
argument :following_id, :uuid, allow_nil?: false
run fn input, context ->
actor = context.actor
Mixer.Accounts.Follow
|> Ash.Query.filter(
Ash.Expr.expr(
follower_id == ^actor.id and following_id == ^input.arguments.following_id
)
)
|> Ash.read_one(authorize?: false)
|> case do
{:ok, nil} -> :ok
{:ok, follow} -> Ash.destroy(follow, authorize?: false)
{:error, error} -> {:error, error}
end
end
end
end
policies do
policy action_type(:read) do
authorize_if always()
end
policy action(:follow) do
authorize_if actor_present()
end
policy action(:unfollow) do
authorize_if actor_present()
end
end
attributes do
uuid_primary_key :id
create_timestamp :created_at
end
relationships do
belongs_to :follower, Mixer.Accounts.User do
primary_key? true
allow_nil? false
attribute_writable? true
end
belongs_to :following, Mixer.Accounts.User do
primary_key? true
allow_nil? false
attribute_writable? true
end
end
identities do
identity :unique_follow, [:follower_id, :following_id]
end
end

View File

@@ -1,10 +1,12 @@
defmodule Mixer.Accounts.User do
import Ash.Expr
use Ash.Resource,
otp_app: :mixer,
domain: Mixer.Accounts,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer],
extensions: [AshAuthentication]
extensions: [AshAuthentication, AshTypescript.Resource]
authentication do
add_ons do
@@ -35,6 +37,7 @@ defmodule Mixer.Accounts.User do
password :password do
identity_field :email
hash_provider AshAuthentication.BcryptProvider
require_confirmed_with :confirmed_at
resettable do
sender Mixer.Accounts.User.Senders.SendPasswordResetEmail
@@ -66,6 +69,10 @@ defmodule Mixer.Accounts.User do
repo Mixer.Repo
end
typescript do
type_name "users"
end
actions do
defaults [:read]
@@ -170,9 +177,21 @@ defmodule Mixer.Accounts.User do
sensitive? true
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
change set_attribute(:email, arg(:email))
# Sets the username from the argument
change set_attribute(:username, arg(:username))
# Hashes the provided password
change AshAuthentication.Strategy.Password.HashPasswordChange
@@ -204,6 +223,18 @@ defmodule Mixer.Accounts.User do
get_by :email
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
argument :reset_token, :string do
allow_nil? false
@@ -249,6 +280,15 @@ defmodule Mixer.Accounts.User do
allow_nil? true
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_identity :unique_email
upsert_fields [:email]
@@ -259,6 +299,37 @@ defmodule Mixer.Accounts.User do
change {AshAuthentication.Strategy.RememberMe.MaybeGenerateTokenChange,
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
allow_nil? false
end
@@ -282,6 +353,18 @@ defmodule Mixer.Accounts.User do
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
authorize_if always()
end
policy action_type(:read) do
authorize_if always()
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
attributes do
@@ -297,6 +380,23 @@ defmodule Mixer.Accounts.User do
end
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
relationships do
@@ -307,9 +407,42 @@ defmodule Mixer.Accounts.User do
has_many :tweet_likes, Mixer.Posts.TweetLike
has_many :tweets, Mixer.Posts.Tweet
has_many :followers, Mixer.Accounts.Follow do
destination_attribute :following_id
end
has_many :following, Mixer.Accounts.Follow do
destination_attribute :follower_id
end
end
aggregates do
count :follower_count, :followers do
public? true
end
count :following_count, :following do
public? true
end
exists :am_i_following, :followers do
public? true
filter expr(follower_id == ^actor(:id))
end
first :my_follow_id, :followers, :id do
public? true
filter expr(follower_id == ^actor(:id))
end
end
identities do
identity :unique_email, [:email]
identity :unique_username, [:username] do
eager_check_with Mixer.Accounts
message "is already taken"
nils_distinct? true
end
end
end

View File

@@ -21,8 +21,7 @@ defmodule Mixer.Accounts.User.Senders.SendMagicLinkEmail do
end
new()
# TODO: Replace with your email
|> from({"noreply", "noreply@example.com"})
|> from({"noreply", "noreply@jimweaver.com"})
|> to(to_string(email))
|> subject("Your login link")
|> html_body(body(token: token, email: email))
@@ -31,10 +30,86 @@ defmodule Mixer.Accounts.User.Senders.SendMagicLinkEmail do
defp body(params) do
# NOTE: You may have to change this to match your magic link acceptance URL.
link = url(~p"/magic_link/#{params[:token]}")
email_template(
"Your magic link",
"Hello, #{params[:email]}!",
"""
<p>Hello, #{params[:email]}! Click this link to sign in:</p>
<p><a href="#{url(~p"/magic_link/#{params[:token]}")}">#{url(~p"/magic_link/#{params[:token]}")}</a></p>
<p style="margin:0 0 20px 0;color:#4B5563;font-size:16px;line-height:1.6;">
Use the button below to sign in to Mixer. This link is valid for a short time and can only be used once.
</p>
<p style="margin:0 0 32px 0;color:#4B5563;font-size:16px;line-height:1.6;">
If you didn't request this, you can safely ignore this email.
</p>
""",
link,
"Sign In to Mixer"
)
end
defp email_template(title, greeting, content, button_url, button_label) do
"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>#{title}</title>
</head>
<body style="margin:0;padding:0;background-color:#09090f;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#09090f;padding:48px 16px;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;width:100%;">
<!-- Header -->
<tr>
<td style="background-color:#0e0e18;border-radius:12px 12px 0 0;padding:32px 40px;text-align:center;border:1px solid #1e1e30;border-bottom:none;">
<div style="font-size:28px;font-style:italic;font-weight:400;color:#e8e8f0;letter-spacing:-0.02em;font-family:Georgia,'Times New Roman',serif;">Mixer</div>
<div style="font-size:11px;color:#4a4a6a;margin-top:6px;letter-spacing:0.1em;text-transform:uppercase;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">Your social feed</div>
</td>
</tr>
<!-- Accent bar -->
<tr>
<td style="background-color:#7c3aed;height:2px;font-size:0;line-height:0;border-left:1px solid #1e1e30;border-right:1px solid #1e1e30;">&nbsp;</td>
</tr>
<!-- Body -->
<tr>
<td style="background-color:#111120;padding:40px 40px 32px 40px;border:1px solid #1e1e30;border-top:none;border-bottom:none;">
<h1 style="margin:0 0 20px 0;font-size:20px;font-weight:600;color:#e8e8f0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;letter-spacing:-0.01em;">#{greeting}</h1>
#{content}
<!-- CTA Button -->
<table cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="border-radius:8px;background-color:#7c3aed;">
<a href="#{button_url}" style="display:inline-block;padding:13px 28px;font-size:14px;font-weight:600;color:#ffffff;text-decoration:none;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;letter-spacing:0.01em;border-radius:8px;">#{button_label}</a>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color:#0e0e18;border-radius:0 0 12px 12px;padding:24px 40px;border:1px solid #1e1e30;border-top:1px solid #1e1e30;">
<p style="margin:0 0 8px 0;font-size:12px;color:#4a4a6a;line-height:1.6;font-family:'Courier New',Courier,monospace;letter-spacing:0.02em;">
This is an automated message — replies to this address are not monitored.
</p>
<p style="margin:0;font-size:12px;color:#35354a;line-height:1.6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">
You received this because you have an account on Mixer.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
"""
end
end

View File

@@ -13,8 +13,7 @@ defmodule Mixer.Accounts.User.Senders.SendNewUserConfirmationEmail do
@impl true
def send(user, token, _) do
new()
# TODO: Replace with your email
|> from({"noreply", "noreply@example.com"})
|> from({"noreply", "noreply@jimweaver.com"})
|> to(to_string(user.email))
|> subject("Confirm your email address")
|> html_body(body(token: token))
@@ -22,11 +21,86 @@ defmodule Mixer.Accounts.User.Senders.SendNewUserConfirmationEmail do
end
defp body(params) do
url = url(~p"/confirm_new_user/#{params[:token]}")
link = url(~p"/confirm_new_user/#{params[:token]}")
email_template(
"Confirm your email",
"Welcome to Mixer!",
"""
<p>Click this link to confirm your email:</p>
<p><a href="#{url}">#{url}</a></p>
<p style="margin:0 0 20px 0;color:#4B5563;font-size:16px;line-height:1.6;">
Thanks for signing up. Just one more step — confirm your email address to activate your account.
</p>
<p style="margin:0 0 32px 0;color:#4B5563;font-size:16px;line-height:1.6;">
If you didn't create an account on Mixer, you can safely ignore this email.
</p>
""",
link,
"Confirm Email Address"
)
end
defp email_template(title, greeting, content, button_url, button_label) do
"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>#{title}</title>
</head>
<body style="margin:0;padding:0;background-color:#09090f;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#09090f;padding:48px 16px;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;width:100%;">
<!-- Header -->
<tr>
<td style="background-color:#0e0e18;border-radius:12px 12px 0 0;padding:32px 40px;text-align:center;border:1px solid #1e1e30;border-bottom:none;">
<div style="font-size:28px;font-style:italic;font-weight:400;color:#e8e8f0;letter-spacing:-0.02em;font-family:Georgia,'Times New Roman',serif;">Mixer</div>
<div style="font-size:11px;color:#4a4a6a;margin-top:6px;letter-spacing:0.1em;text-transform:uppercase;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">Your social feed</div>
</td>
</tr>
<!-- Accent bar -->
<tr>
<td style="background-color:#7c3aed;height:2px;font-size:0;line-height:0;border-left:1px solid #1e1e30;border-right:1px solid #1e1e30;">&nbsp;</td>
</tr>
<!-- Body -->
<tr>
<td style="background-color:#111120;padding:40px 40px 32px 40px;border:1px solid #1e1e30;border-top:none;border-bottom:none;">
<h1 style="margin:0 0 20px 0;font-size:20px;font-weight:600;color:#e8e8f0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;letter-spacing:-0.01em;">#{greeting}</h1>
#{content}
<!-- CTA Button -->
<table cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="border-radius:8px;background-color:#7c3aed;">
<a href="#{button_url}" style="display:inline-block;padding:13px 28px;font-size:14px;font-weight:600;color:#ffffff;text-decoration:none;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;letter-spacing:0.01em;border-radius:8px;">#{button_label}</a>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color:#0e0e18;border-radius:0 0 12px 12px;padding:24px 40px;border:1px solid #1e1e30;border-top:1px solid #1e1e30;">
<p style="margin:0 0 8px 0;font-size:12px;color:#4a4a6a;line-height:1.6;font-family:'Courier New',Courier,monospace;letter-spacing:0.02em;">
This is an automated message — replies to this address are not monitored.
</p>
<p style="margin:0;font-size:12px;color:#35354a;line-height:1.6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">
You received this because you signed up for Mixer.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
"""
end
end

View File

@@ -13,8 +13,7 @@ defmodule Mixer.Accounts.User.Senders.SendPasswordResetEmail do
@impl true
def send(user, token, _) do
new()
# TODO: Replace with your email
|> from({"noreply", "noreply@example.com"})
|> from({"noreply", "noreply@jimweaver.com"})
|> to(to_string(user.email))
|> subject("Reset your password")
|> html_body(body(token: token))
@@ -22,11 +21,86 @@ defmodule Mixer.Accounts.User.Senders.SendPasswordResetEmail do
end
defp body(params) do
url = url(~p"/password-reset/#{params[:token]}")
link = url(~p"/password-reset/#{params[:token]}")
email_template(
"Reset your password",
"Password reset request",
"""
<p>Click this link to reset your password:</p>
<p><a href="#{url}">#{url}</a></p>
<p style="margin:0 0 20px 0;color:#4B5563;font-size:16px;line-height:1.6;">
We received a request to reset the password for your Mixer account. Click the button below to choose a new one.
</p>
<p style="margin:0 0 32px 0;color:#4B5563;font-size:16px;line-height:1.6;">
If you didn't request a password reset, you can safely ignore this email — your password will not change.
</p>
""",
link,
"Reset My Password"
)
end
defp email_template(title, greeting, content, button_url, button_label) do
"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>#{title}</title>
</head>
<body style="margin:0;padding:0;background-color:#09090f;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#09090f;padding:48px 16px;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;width:100%;">
<!-- Header -->
<tr>
<td style="background-color:#0e0e18;border-radius:12px 12px 0 0;padding:32px 40px;text-align:center;border:1px solid #1e1e30;border-bottom:none;">
<div style="font-size:28px;font-style:italic;font-weight:400;color:#e8e8f0;letter-spacing:-0.02em;font-family:Georgia,'Times New Roman',serif;">Mixer</div>
<div style="font-size:11px;color:#4a4a6a;margin-top:6px;letter-spacing:0.1em;text-transform:uppercase;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">Your social feed</div>
</td>
</tr>
<!-- Accent bar -->
<tr>
<td style="background-color:#7c3aed;height:2px;font-size:0;line-height:0;border-left:1px solid #1e1e30;border-right:1px solid #1e1e30;">&nbsp;</td>
</tr>
<!-- Body -->
<tr>
<td style="background-color:#111120;padding:40px 40px 32px 40px;border:1px solid #1e1e30;border-top:none;border-bottom:none;">
<h1 style="margin:0 0 20px 0;font-size:20px;font-weight:600;color:#e8e8f0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;letter-spacing:-0.01em;">#{greeting}</h1>
#{content}
<!-- CTA Button -->
<table cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="border-radius:8px;background-color:#7c3aed;">
<a href="#{button_url}" style="display:inline-block;padding:13px 28px;font-size:14px;font-weight:600;color:#ffffff;text-decoration:none;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;letter-spacing:0.01em;border-radius:8px;">#{button_label}</a>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color:#0e0e18;border-radius:0 0 12px 12px;padding:24px 40px;border:1px solid #1e1e30;border-top:1px solid #1e1e30;">
<p style="margin:0 0 8px 0;font-size:12px;color:#4a4a6a;line-height:1.6;font-family:'Courier New',Courier,monospace;letter-spacing:0.02em;">
This is an automated message — replies to this address are not monitored.
</p>
<p style="margin:0;font-size:12px;color:#35354a;line-height:1.6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">
You received this because a password reset was requested for your Mixer account.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
"""
end
end

View File

@@ -10,6 +10,10 @@ defmodule Mixer.Application do
children = [
MixerWeb.Telemetry,
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},
{Phoenix.PubSub, name: Mixer.PubSub},
# 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

@@ -3,6 +3,22 @@ defmodule Mixer.Posts do
otp_app: :mixer,
extensions: [AshTypescript.Rpc, AshAdmin.Domain]
typescript_rpc do
resource Mixer.Posts.Tweet do
rpc_action :create_tweet, :create
rpc_action :like_tweet, :like
rpc_action :read_tweet, :read
rpc_action :read_following_feed, :following_feed
rpc_action :unlike_tweet, :unlike
rpc_action :update_tweet, :update
rpc_action :destroy_tweet, :destroy
end
resource Mixer.Posts.Media do
rpc_action :read_media, :read
end
end
admin do
show? true
end
@@ -12,19 +28,4 @@ defmodule Mixer.Posts do
resource Mixer.Posts.TweetLike
resource Mixer.Posts.Media
end
typescript_rpc do
resource Mixer.Posts.Tweet do
rpc_action :create_tweet, :create
rpc_action :like_tweet, :like
rpc_action :read_tweet, :read
rpc_action :unlike_tweet, :unlike
rpc_action :update_tweet, :update
rpc_action :destroy_tweet, :destroy
end
resource Mixer.Posts.Media do
rpc_action :read_media, :read
end
end
end

View File

@@ -38,6 +38,24 @@ defmodule Mixer.Posts.Media do
end
end
policies do
policy action_type(:read) do
authorize_if always()
end
policy action(:upload) do
authorize_if actor_present()
end
policy action(:link_to_tweet) do
authorize_if relates_to_actor_via(:user)
end
policy action_type(:destroy) do
authorize_if relates_to_actor_via(:user)
end
end
attributes do
uuid_primary_key :id
@@ -64,22 +82,4 @@ defmodule Mixer.Posts.Media do
public? true
end
end
policies do
policy action_type(:read) do
authorize_if always()
end
policy action(:upload) do
authorize_if actor_present()
end
policy action(:link_to_tweet) do
authorize_if relates_to_actor_via(:user)
end
policy action_type(:destroy) do
authorize_if relates_to_actor_via(:user)
end
end
end

View File

@@ -10,7 +10,8 @@ defmodule Mixer.Posts.MediaUploader do
if ext in @extensions, do: :ok, else: {:error, "unsupported file type #{ext}"}
end
def storage_dir(_version, {_file, scope}), do: "uploads/media/#{scope.user_id}/#{scope.media_id}"
def storage_dir(_version, {_file, scope}),
do: "uploads/media/#{scope.user_id}/#{scope.media_id}"
def filename(_version, {file, _scope}) do
Path.basename(file.file_name, Path.extname(file.file_name))

View File

@@ -12,10 +12,10 @@ defmodule Mixer.Posts.Tweet do
postgres do
table "tweets"
repo Mixer.Repo
end
typescript do
type_name "tweets"
references do
reference :parent_tweet, on_delete: :delete
end
end
state_machine do
@@ -27,15 +27,27 @@ defmodule Mixer.Posts.Tweet do
end
end
typescript do
type_name "tweets"
end
actions do
defaults [:read, :destroy]
defaults [:read]
read :following_feed do
filter expr(
user_id == ^actor(:id) or
exists(user.followers, follower_id == ^actor(:id))
)
end
create :create do
upsert? true
accept [:content]
accept [:content, :parent_tweet_id]
argument :media_id, :uuid, allow_nil?: true
change relate_actor(:user)
change transition_state(:posted)
change fn changeset, context ->
case Ash.Changeset.get_argument(changeset, :media_id) do
nil ->
@@ -45,13 +57,58 @@ defmodule Mixer.Posts.Tweet do
Ash.Changeset.after_action(changeset, fn _changeset, tweet ->
Mixer.Posts.Media
|> Ash.get!(media_id, authorize?: false)
|> Ash.Changeset.for_update(:link_to_tweet, %{tweet_id: tweet.id}, actor: context.actor)
|> Ash.Changeset.for_update(:link_to_tweet, %{tweet_id: tweet.id},
actor: context.actor
)
|> Ash.update!()
{:ok, tweet}
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
update :update do
@@ -66,6 +123,7 @@ defmodule Mixer.Posts.Tweet do
Ash.Changeset.after_action(changeset, fn _changeset, tweet ->
case ensure_like(tweet, context.actor) do
{:created, _like} ->
Mixer.Metrics.track_like(tweet.id, user_id: context.actor && context.actor.id)
increment_likes(tweet, context.actor)
{:noop, _like} ->
@@ -86,6 +144,7 @@ defmodule Mixer.Posts.Tweet do
Ash.Changeset.after_action(changeset, fn _changeset, tweet ->
case remove_like(tweet, context.actor) do
{:deleted, _like} ->
Mixer.Metrics.track_unlike(tweet.id, user_id: context.actor && context.actor.id)
decrement_likes(tweet, context.actor)
{:noop, _like} ->
@@ -111,6 +170,33 @@ defmodule Mixer.Posts.Tweet do
end
end
policies do
policy action_type(:read) do
authorize_if always()
end
policy action_type(:create) do
authorize_if actor_present()
end
policy action(:update) do
authorize_if relates_to_actor_via(:user)
end
policy action(:destroy) do
authorize_if relates_to_actor_via(:user)
authorize_if relates_to_actor_via([:parent_tweet, :user])
end
policy action(:like) do
authorize_if actor_present()
end
policy action(:unlike) do
authorize_if actor_present()
end
end
attributes do
uuid_primary_key :id
@@ -145,6 +231,18 @@ defmodule Mixer.Posts.Tweet do
public? true
end
belongs_to :parent_tweet, Mixer.Posts.Tweet do
attribute_type :uuid
attribute_writable? true
allow_nil? true
public? true
end
has_many :comments, Mixer.Posts.Tweet do
destination_attribute :parent_tweet_id
public? true
end
has_many :media, Mixer.Posts.Media do
public? true
end
@@ -156,41 +254,31 @@ defmodule Mixer.Posts.Tweet do
calculate :user_email, :string, expr(user.email) do
public? true
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
aggregates do
count :comment_count, :comments do
public? true
end
exists :liked_by_me, :tweet_likes do
public? true
filter expr(user_id == ^actor(:id))
end
end
policies do
policy action_type(:read) do
authorize_if always()
end
policy action_type(:create) do
authorize_if actor_present()
end
policy action(:update) do
authorize_if relates_to_actor_via(:user)
end
policy action(:destroy) do
authorize_if relates_to_actor_via(:user)
end
policy action(:like) do
authorize_if actor_present()
end
policy action(:unlike) do
authorize_if actor_present()
end
end
defp ensure_like(_tweet, nil), do: {:error, Ash.Error.Forbidden.exception([])}
defp ensure_like(tweet, actor) do

View File

@@ -23,6 +23,20 @@ defmodule Mixer.Posts.TweetLike do
end
end
policies do
policy action_type(:read) do
authorize_if always()
end
policy action(:create) do
authorize_if actor_present()
end
policy action_type(:destroy) do
authorize_if relates_to_actor_via(:user)
end
end
attributes do
uuid_primary_key :id
@@ -52,18 +66,4 @@ defmodule Mixer.Posts.TweetLike do
identities do
identity :unique_user_tweet, [:tweet_id, :user_id]
end
policies do
policy action_type(:read) do
authorize_if always()
end
policy action(:create) do
authorize_if actor_present()
end
policy action_type(:destroy) do
authorize_if relates_to_actor_via(:user)
end
end
end

View File

@@ -9,12 +9,15 @@ defmodule MixerWeb.AuthOverrides do
# For a complete reference, see https://hexdocs.pm/ash_authentication_phoenix/ui-overrides.html
# override AshAuthentication.Phoenix.Components.Banner do
# set :image_url, "https://media.giphy.com/media/g7GKcSzwQfugw/giphy.gif"
# set :text_class, "bg-red-500"
# end
# override AshAuthentication.Phoenix.Components.SignIn do
# set :show_banner, false
# end
override AshAuthentication.Phoenix.Components.Banner do
set :image_url, nil
set :dark_image_url, nil
set :text, "⬡ Mixer"
set :text_class, "text-3xl font-bold tracking-tight"
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

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

@@ -4,8 +4,23 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<.live_title default="Mixer" suffix=" · Phoenix Framework">
{assigns[:page_title]}
<link rel="icon" href={~p"/favicon.ico"} sizes="any" />
<% meta_title = assigns[:page_title] || "Mixer"
meta_description =
assigns[:page_description] ||
"Mixer is a social feed for all. Come join the conversation — built with Elixir." %>
<meta name="description" content={meta_description} />
<meta name="robots" content={assigns[:robots] || "index, follow"} />
<meta property="og:site_name" content="Mixer" />
<meta property="og:title" content={meta_title} />
<meta property="og:description" content={meta_description} />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content={meta_title} />
<meta name="twitter:description" content={meta_description} />
<.live_title suffix=" · Mixer">
{assigns[:page_title] || "Mixer"}
</.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
<script defer phx-track-static type="module" src={~p"/assets/app.js"}>

View File

@@ -8,7 +8,37 @@ SPDX-License-Identifier: MIT
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<.live_title default="AshTypescript">Page</.live_title>
<link rel="icon" href={~p"/favicon.ico"} sizes="any" />
<% {spa_title, spa_description} =
case @page do
"feed" -> {"Mixer · Feed", "See the latest posts from everyone on Mixer."}
"tweet" -> {"Mixer · Post", "Read this post and join the conversation on Mixer."}
"following" -> {"Mixer · Following", "Posts from the people you follow on Mixer."}
"profile" -> {"Mixer · My Profile", "View and manage your Mixer profile."}
"users" -> {"Mixer · People", "Discover and follow people on Mixer."}
"user-detail" -> {"Mixer · Profile", "View this user's profile and posts on Mixer."}
_ -> {"Mixer", "A social feed built in Elixir."}
end %>
<meta name="description" content={spa_description} />
<meta name="robots" content="index, follow" />
<meta property="og:site_name" content="Mixer" />
<meta property="og:title" content={spa_title} />
<meta property="og:description" content={spa_description} />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content={spa_title} />
<meta name="twitter:description" content={spa_description} />
<.live_title default="Mixer">
{case @page do
"feed" -> "Mixer · Feed"
"tweet" -> "Mixer · Post"
"following" -> "Mixer · Following"
"profile" -> "Mixer · My Profile"
"users" -> "Mixer · People"
"user-detail" -> "Mixer · Profile"
_ -> "Mixer"
end}
</.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
</head>
<body>

View File

@@ -35,8 +35,11 @@ defmodule MixerWeb.AuthController do
You can confirm your account using the link we sent to you, or by resetting your password.
"""
{_, %AshAuthentication.Errors.UnconfirmedUser{}} ->
"You must confirm your email address before signing in. Please check your inbox for a confirmation email."
_ ->
"Incorrect email or password"
"Incorrect email or password or unconfirmed email"
end
conn

View File

@@ -2,18 +2,42 @@ defmodule MixerWeb.PageController do
use MixerWeb, :controller
def home(conn, _params) do
render(conn, :home)
if conn.assigns[:current_user] do
redirect(conn, to: ~p"/feed")
else
conn
|> assign(:page_title, "Mixer")
|> render(:home)
end
end
def index(conn, _params) do
render_spa(conn, nil)
render_spa(conn, %{page: "feed", tweet_id: nil, user_id: nil})
end
def show(conn, %{"tweet_id" => tweet_id}) do
render_spa(conn, tweet_id)
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})
end
defp render_spa(conn, tweet_id) do
def following(conn, _params) do
render_spa(conn, %{page: "following", tweet_id: nil, user_id: nil})
end
def profile(conn, _params) do
render_spa(conn, %{page: "profile", tweet_id: nil, user_id: nil})
end
def users_index(conn, _params) do
render_spa(conn, %{page: "users", tweet_id: nil, user_id: nil})
end
def user_show(conn, %{"user_id" => user_id}) do
render_spa(conn, %{page: "user-detail", tweet_id: nil, user_id: user_id})
end
defp render_spa(conn, %{page: page, tweet_id: tweet_id, user_id: user_id}) do
asset_host = Application.get_env(:waffle, :asset_host, "http://localhost:3900")
bucket = Application.get_env(:waffle, :bucket, "mixer-bucket")
@@ -22,7 +46,9 @@ defmodule MixerWeb.PageController do
|> render(:index,
current_user: conn.assigns[:current_user],
media_host: "#{asset_host}/#{bucket}",
tweet_id: tweet_id
page: page,
tweet_id: tweet_id,
user_id: user_id
)
end
end

View File

@@ -5,7 +5,7 @@
<p class="text-base-content/60 text-lg mb-10">A social feed built with Ash &amp; Phoenix.</p>
<div class="flex flex-col sm:flex-row gap-3 justify-center">
<a href="/register" class="btn btn-primary btn-lg rounded-full px-8">Create account</a>
<a href="/auth/sign-in" class="btn btn-ghost btn-lg rounded-full px-8">Sign in</a>
<a href="/sign-in" class="btn btn-ghost btn-lg rounded-full px-8">Sign in</a>
</div>
<p class="mt-8 text-sm text-base-content/40">
Already have an account?

View File

@@ -1,6 +1,15 @@
<div id="app"
<div
id="app"
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-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-tweet-id={@tweet_id || ""}>
data-page={@page}
data-tweet-id={@tweet_id || ""}
data-user-id={@user_id || ""}
>
</div>

View File

@@ -2,6 +2,7 @@ defmodule MixerWeb.UploadController do
use MixerWeb, :controller
alias Mixer.Posts.MediaUploader
alias Mixer.Accounts.AvatarUploader
def create(conn, %{"file" => %Plug.Upload{} = upload}) do
actor = conn.assigns[:current_user]
@@ -46,4 +47,50 @@ defmodule MixerWeb.UploadController do
|> put_status(:bad_request)
|> json(%{error: "no file provided"})
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.
# Append a timestamp so the browser doesn't serve a stale cached image
# when the user updates their avatar (the URL changes, S3 ignores the param).
thumb_key = "avatars/#{actor.id}/thumb.webp?v=#{System.system_time(:millisecond)}"
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

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

@@ -40,9 +40,14 @@ defmodule MixerWeb.Router do
get "/", PageController, :home
get "/feed", PageController, :index
get "/feed/:tweet_id", PageController, :show
get "/following", PageController, :following
get "/profile", PageController, :profile
get "/users", PageController, :users_index
get "/users/:user_id", PageController, :user_show
post "/rpc/run", AshTypescriptRpcController, :run
post "/rpc/validate", AshTypescriptRpcController, :validate
post "/upload", UploadController, :create
post "/upload/avatar", UploadController, :upload_avatar
auth_routes AuthController, Mixer.Accounts.User, path: "/auth"
sign_out_route AuthController
@@ -70,6 +75,7 @@ defmodule MixerWeb.Router do
# Remove this if you do not use the magic link strategy.
magic_sign_in_route(Mixer.Accounts.User, :magic_link,
live_view: MixerWeb.MagicSignInLive,
auth_routes_prefix: "/auth",
overrides: [MixerWeb.AuthOverrides, Elixir.AshAuthentication.Phoenix.Overrides.DaisyUI]
)

16
mix.exs
View File

@@ -91,7 +91,8 @@ defmodule Mixer.MixProject do
{:ex_aws, "~> 2.1.2"},
{:ex_aws_s3, "~> 2.0"},
{:hackney, "~> 1.9"},
{:sweet_xml, "~> 0.6"}
{:sweet_xml, "~> 0.6"},
{:ecto_ch, "~> 0.3"}
]
end
@@ -133,18 +134,21 @@ defmodule Mixer.MixProject do
build: [
"ash-framework": [
# The description tells people how to use this skill.
description: "Use this skill working with Ash Framework or any of its extensions. Always consult this when making any domain changes, features or fixes.",
description:
"Use this skill working with Ash Framework or any of its extensions. Always consult this when making any domain changes, features or fixes.",
# Include all Ash dependencies
usage_rules: [:ash, ~r/^ash_/]
],
"phoenix-framework": [
description: "Use this skill working with Phoenix Framework. Consult this when working with the web layer, controllers, views, liveviews etc.",
description:
"Use this skill working with Phoenix Framework. Consult this when working with the web layer, controllers, views, liveviews etc.",
# Include all Phoenix dependencies
usage_rules: [:phoenix, ~r/^phoenix_/]
]
]
]
]
[
file: "AGENTS.md",
usage_rules: ["usage_rules:all"],
@@ -152,11 +156,13 @@ defmodule Mixer.MixProject do
location: ".agents/skills",
build: [
"ash-framework": [
description: "Use this skill working with Ash Framework or any of its extensions. Always consult this when making any domain changes, features or fixes.",
description:
"Use this skill working with Ash Framework or any of its extensions. Always consult this when making any domain changes, features or fixes.",
usage_rules: [:ash, ~r/^ash_/]
],
"phoenix-framework": [
description: "Use this skill working with Phoenix Framework. Consult this when working with the web layer, controllers, views, liveviews etc.",
description:
"Use this skill working with Phoenix Framework. Consult this when working with the web layer, controllers, views, liveviews etc.",
usage_rules: [:phoenix, ~r/^phoenix_/]
]
]

View File

@@ -20,6 +20,7 @@
"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"},
"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"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"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"},
"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_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"},
"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"},

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

@@ -18,7 +18,8 @@ defmodule Mixer.Repo.Migrations.SetupPostsAndTweets do
name: "tweets_user_id_fkey",
type: :uuid,
prefix: "public"
), null: false
),
null: false
add :state, :text, null: false, default: "drafted"
end

View File

@@ -22,7 +22,8 @@ defmodule Mixer.Repo.Migrations.AddPostsMediaS3 do
name: "media_tweet_id_fkey",
type: :uuid,
prefix: "public"
), null: false
),
null: false
end
end

View File

@@ -17,7 +17,8 @@ defmodule Mixer.Repo.Migrations.AddUserIdToMediaAndAllowNullTweetId do
name: "media_user_id_fkey",
type: :uuid,
prefix: "public"
), null: false
),
null: false
end
end

View File

@@ -17,7 +17,8 @@ defmodule Mixer.Repo.Migrations.AddTweetLikes do
name: "tweet_likes_tweet_id_fkey",
type: :uuid,
prefix: "public"
), null: false
),
null: false
add :user_id,
references(:users,
@@ -25,7 +26,8 @@ defmodule Mixer.Repo.Migrations.AddTweetLikes do
name: "tweet_likes_user_id_fkey",
type: :uuid,
prefix: "public"
), null: false
),
null: false
end
create unique_index(:tweet_likes, [:tweet_id, :user_id],

View File

@@ -0,0 +1,53 @@
defmodule Mixer.Repo.Migrations.FollowFeature 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
create table(:follows, primary_key: false) do
add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true
add :created_at, :utc_datetime_usec,
null: false,
default: fragment("(now() AT TIME ZONE 'utc')")
add :follower_id,
references(:users,
column: :id,
name: "follows_follower_id_fkey",
type: :uuid,
prefix: "public",
on_delete: :delete_all
), primary_key: true, null: false
add :following_id,
references(:users,
column: :id,
name: "follows_following_id_fkey",
type: :uuid,
prefix: "public",
on_delete: :delete_all
), primary_key: true, null: false
end
create unique_index(:follows, [:follower_id, :following_id],
name: "follows_unique_follow_index"
)
end
def down do
drop_if_exists unique_index(:follows, [:follower_id, :following_id],
name: "follows_unique_follow_index"
)
drop constraint(:follows, "follows_follower_id_fkey")
drop constraint(:follows, "follows_following_id_fkey")
drop table(:follows)
end
end

View File

@@ -0,0 +1,30 @@
defmodule Mixer.Repo.Migrations.AddTweetComments 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(:tweets) do
add :parent_tweet_id,
references(:tweets,
column: :id,
name: "tweets_parent_tweet_id_fkey",
type: :uuid,
prefix: "public",
on_delete: :delete_all
)
end
end
def down do
drop constraint(:tweets, "tweets_parent_tweet_id_fkey")
alter table(:tweets) do
remove :parent_tweet_id
end
end
end

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,125 @@
{
"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": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "created_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "follows_follower_id_fkey",
"on_delete": "delete",
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "users"
},
"scale": null,
"size": null,
"source": "follower_id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "follows_following_id_fkey",
"on_delete": "delete",
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "users"
},
"scale": null,
"size": null,
"source": "following_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"create_table_options": null,
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "F54EC67C792D0DBDA389BB372D0403325AEAD4C232678C19BB951AA058813F0A",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "follows_unique_follow_index",
"keys": [
{
"type": "atom",
"value": "follower_id"
},
{
"type": "atom",
"value": "following_id"
}
],
"name": "unique_follow",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mixer.Repo",
"schema": null,
"table": "follows"
}

View File

@@ -0,0 +1,154 @@
{
"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": "content",
"type": "text"
},
{
"allow_nil?": false,
"default": "0",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "likes",
"type": "bigint"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "tweets_user_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "users"
},
"scale": null,
"size": null,
"source": "user_id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "inserted_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": false,
"default": "\"drafted\"",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "state",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "tweets_parent_tweet_id_fkey",
"on_delete": "delete",
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "tweets"
},
"scale": null,
"size": null,
"source": "parent_tweet_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"create_table_options": null,
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "090E928120B2CFAA2B8D5D2EB43AD6E782ABB552AFC211BB6173D6337F487218",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mixer.Repo",
"schema": null,
"table": "tweets"
}

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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 B

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 71 48" fill="currentColor" aria-hidden="true">
<path
d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.077.057c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728a13 13 0 0 0 1.182 1.106c1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zl-.006.006-.036-.004.021.018.012.053Za.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zl-.008.01.005.026.024.014Z"
fill="#FD4F00"
/>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

View File

@@ -0,0 +1,5 @@
# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
#
# To ban all spiders from the entire site uncomment the next two lines:
# User-agent: *
# Disallow: /

BIN
priv/static/robots.txt.gz Normal file

Binary file not shown.

2
rel/env.sh.eex Normal file
View File

@@ -0,0 +1,2 @@
#!/bin/sh
export RELEASE_DISTRIBUTION=none

View File

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

View File

@@ -1,8 +1,32 @@
defmodule MixerWeb.PageControllerTest do
use MixerWeb.ConnCase
test "GET /", %{conn: conn} do
test "GET / redirects to /feed when logged in", %{conn: conn} do
user =
Mixer.Accounts.User
|> Ash.Changeset.for_create(
:register_with_password,
%{
email: "test@example.com",
password: "Password1!",
password_confirmation: "Password1!",
username: "testuser"
},
authorize?: false
)
|> Ash.create!()
conn =
conn
|> Plug.Test.init_test_session(%{})
|> AshAuthentication.Plug.Helpers.store_in_session(user)
|> get(~p"/")
assert redirected_to(conn) == ~p"/feed"
end
test "GET / renders the home page for unauthenticated users", %{conn: conn} do
conn = get(conn, ~p"/")
assert html_response(conn, 200) =~ "Peace of mind from prototype to production"
assert html_response(conn, 200) =~ "Mixer"
end
end