added comments to tweets

This commit is contained in:
2026-04-06 14:11:10 -04:00
parent 6927f6eb9b
commit faa96d88f5
7 changed files with 587 additions and 9 deletions

View File

@@ -696,6 +696,104 @@ html, body {
margin-bottom: 1rem; 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) ── */ /* ── Clickable media thumb (used in detail view) ── */
.mx-media-thumb { .mx-media-thumb {
background: none; background: none;

View File

@@ -644,6 +644,7 @@ export async function validateReadMedia(
export type CreateTweetInput = { export type CreateTweetInput = {
content: string; content: string;
parentTweetId?: UUID | null;
mediaId?: UUID; mediaId?: UUID;
}; };

View File

@@ -71,16 +71,20 @@ export type mediaAttributesOnlySchema = {
// tweets Schema // tweets Schema
export type tweetsResourceSchema = { export type tweetsResourceSchema = {
__type: "Resource"; __type: "Resource";
__primitiveFields: "id" | "content" | "likes" | "userId" | "insertedAt" | "state" | "likedByMe" | "userEmail"; __primitiveFields: "id" | "content" | "likes" | "userId" | "insertedAt" | "state" | "parentTweetId" | "commentCount" | "likedByMe" | "userEmail";
id: UUID; id: UUID;
content: string; content: string;
likes: number; likes: number;
userId: UUID; userId: UUID;
insertedAt: UtcDateTimeUsec; insertedAt: UtcDateTimeUsec;
state: "posted" | "drafted"; state: "posted" | "drafted";
parentTweetId: UUID | null;
commentCount: number;
likedByMe: boolean; likedByMe: boolean;
userEmail: string | null; userEmail: string | null;
user: { __type: "Relationship"; __resource: usersResourceSchema; }; 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; }; media: { __type: "Relationship"; __array: true; __resource: mediaResourceSchema; };
}; };
@@ -88,13 +92,14 @@ export type tweetsResourceSchema = {
export type tweetsAttributesOnlySchema = { export type tweetsAttributesOnlySchema = {
__type: "Resource"; __type: "Resource";
__primitiveFields: "id" | "content" | "likes" | "userId" | "insertedAt" | "state"; __primitiveFields: "id" | "content" | "likes" | "userId" | "insertedAt" | "state" | "parentTweetId";
id: UUID; id: UUID;
content: string; content: string;
likes: number; likes: number;
userId: UUID; userId: UUID;
insertedAt: UtcDateTimeUsec; insertedAt: UtcDateTimeUsec;
state: "posted" | "drafted"; state: "posted" | "drafted";
parentTweetId: UUID | null;
}; };
@@ -251,6 +256,13 @@ export type tweetsFilterInput = {
in?: Array<"posted" | "drafted">; in?: Array<"posted" | "drafted">;
}; };
parentTweetId?: {
eq?: UUID;
notEq?: UUID;
in?: Array<UUID>;
isNil?: boolean;
};
userEmail?: { userEmail?: {
eq?: string; eq?: string;
notEq?: string; notEq?: string;
@@ -258,6 +270,17 @@ export type tweetsFilterInput = {
isNil?: boolean; isNil?: boolean;
}; };
commentCount?: {
eq?: number;
notEq?: number;
greaterThan?: number;
greaterThanOrEqual?: number;
lessThan?: number;
lessThanOrEqual?: number;
in?: Array<number>;
isNil?: boolean;
};
likedByMe?: { likedByMe?: {
eq?: boolean; eq?: boolean;
notEq?: boolean; notEq?: boolean;
@@ -266,6 +289,10 @@ export type tweetsFilterInput = {
user?: usersFilterInput; user?: usersFilterInput;
parentTweet?: tweetsFilterInput;
comments?: tweetsFilterInput;
media?: mediaFilterInput; media?: mediaFilterInput;
}; };
@@ -280,7 +307,7 @@ export type usersFilterField = (typeof usersFilterFields)[number];
export const mediaFilterFields = ["id", "s3Key", "userId", "tweetId", "user", "tweet"] as const; export const mediaFilterFields = ["id", "s3Key", "userId", "tweetId", "user", "tweet"] as const;
export type mediaFilterField = (typeof mediaFilterFields)[number]; export type mediaFilterField = (typeof mediaFilterFields)[number];
export const tweetsFilterFields = ["id", "content", "likes", "userId", "insertedAt", "state", "userEmail", "likedByMe", "user", "media"] as const; export const tweetsFilterFields = ["id", "content", "likes", "userId", "insertedAt", "state", "parentTweetId", "userEmail", "commentCount", "likedByMe", "user", "parentTweet", "comments", "media"] as const;
export type tweetsFilterField = (typeof tweetsFilterFields)[number]; export type tweetsFilterField = (typeof tweetsFilterFields)[number];
@@ -293,7 +320,7 @@ export type usersSortField = (typeof usersSortFields)[number];
export const mediaSortFields = ["id", "s3Key", "userId", "tweetId"] as const; export const mediaSortFields = ["id", "s3Key", "userId", "tweetId"] as const;
export type mediaSortField = (typeof mediaSortFields)[number]; export type mediaSortField = (typeof mediaSortFields)[number];
export const tweetsSortFields = ["id", "content", "likes", "userId", "insertedAt", "state", "userEmail", "likedByMe"] as const; export const tweetsSortFields = ["id", "content", "likes", "userId", "insertedAt", "state", "parentTweetId", "userEmail", "commentCount", "likedByMe"] as const;
export type tweetsSortField = (typeof tweetsSortFields)[number]; export type tweetsSortField = (typeof tweetsSortFields)[number];

View File

@@ -44,6 +44,8 @@ type Tweet = {
content: string; content: string;
likes: number; likes: number;
likedByMe?: boolean; likedByMe?: boolean;
commentCount?: number;
parentTweetId?: string | null;
userId: string; userId: string;
state: string; state: string;
media?: MediaItem[]; media?: MediaItem[];
@@ -425,6 +427,14 @@ function TweetMedia({ media }: { media: MediaItem[] }) {
); );
} }
function CommentIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H5.17L4 17.17V4h16v12z" />
</svg>
);
}
function TweetCard({ tweet }: { tweet: Tweet }) { function TweetCard({ tweet }: { tweet: Tweet }) {
const { userId: currentUserId } = useContext(AuthCtx); const { userId: currentUserId } = useContext(AuthCtx);
const canModify = !!currentUserId && tweet.userId === currentUserId; const canModify = !!currentUserId && tweet.userId === currentUserId;
@@ -644,6 +654,15 @@ function TweetCard({ tweet }: { tweet: Tweet }) {
</svg> </svg>
<span>{tweet.likes}</span> <span>{tweet.likes}</span>
</button> </button>
<a
href={`/feed/${tweet.id}`}
className="mx-like-btn mx-comment-btn"
onClick={(e) => e.stopPropagation()}
title="View comments"
>
<CommentIcon />
<span>{tweet.commentCount ?? 0}</span>
</a>
</div> </div>
{error && !editing && <p className="mx-compose-error">{error}</p>} {error && !editing && <p className="mx-compose-error">{error}</p>}
@@ -684,8 +703,94 @@ function MediaLightbox({ item, onClose }: { item: MediaItem; onClose: () => void
); );
} }
function ComposeComment({ parentTweetId, onSuccess }: { parentTweetId: string; onSuccess?: () => void }) {
const [text, setText] = useState("");
const [error, setError] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const qc = useQueryClient();
const MAX = 280;
const mutation = useMutation({
mutationFn: async (content: string) => {
const res = await createTweet({
input: { content, parentTweetId },
fields: ["id", "content", "userId", "state"],
headers: buildCSRFHeaders(),
});
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed");
return res.data;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["comments", parentTweetId] });
qc.invalidateQueries({ queryKey: ["tweet", parentTweetId] });
qc.invalidateQueries({ queryKey: ["tweets"] });
qc.invalidateQueries({ queryKey: ["following_tweets"] });
setText("");
setError(null);
onSuccess?.();
},
onError: (e: Error) => setError(e.message),
});
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
submit();
}
}
function submit() {
const trimmed = text.trim();
if (!trimmed) return;
if (trimmed.length > MAX) { setError(`Max ${MAX} characters`); return; }
setError(null);
mutation.mutate(trimmed);
}
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, [text]);
return (
<div className="mx-compose mx-compose--comment">
<div className="mx-compose-avatar mx-compose-avatar--sm">
<span>M</span>
</div>
<div className="mx-compose-body">
<textarea
ref={textareaRef}
className="mx-compose-textarea mx-compose-textarea--sm"
placeholder="Post your reply…"
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
rows={1}
maxLength={MAX + 1}
/>
{error && <p className="mx-compose-error">{error}</p>}
<div className="mx-compose-footer">
<div />
<div className="mx-compose-actions">
<CharCount current={text.length} max={MAX} />
<button
className="mx-btn-post mx-btn-post--sm"
onClick={submit}
disabled={!text.trim() || mutation.isPending}
>
{mutation.isPending ? "Replying…" : "Reply"}
</button>
</div>
</div>
</div>
</div>
);
}
function TweetDetail({ tweetId }: { tweetId: string }) { function TweetDetail({ tweetId }: { tweetId: string }) {
const { userId: currentUserId } = useContext(AuthCtx); const { userId: currentUserId, email } = useContext(AuthCtx);
const [lightboxItem, setLightboxItem] = useState<MediaItem | null>(null); const [lightboxItem, setLightboxItem] = useState<MediaItem | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
@@ -698,7 +803,7 @@ function TweetDetail({ tweetId }: { tweetId: string }) {
queryKey: ["tweet", tweetId], queryKey: ["tweet", tweetId],
queryFn: async () => { queryFn: async () => {
const res = await readTweet({ const res = await readTweet({
fields: ["id", "content", "likes", "likedByMe", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }], fields: ["id", "content", "likes", "likedByMe", "commentCount", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }],
filter: { id: { eq: tweetId } }, filter: { id: { eq: tweetId } },
headers: buildCSRFHeaders(), headers: buildCSRFHeaders(),
}); });
@@ -708,6 +813,21 @@ function TweetDetail({ tweetId }: { tweetId: string }) {
}, },
}); });
const { data: comments, isLoading: commentsLoading } = useQuery({
queryKey: ["comments", tweetId],
queryFn: async () => {
const res = await readTweet({
fields: ["id", "content", "likes", "likedByMe", "parentTweetId", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }],
filter: { parentTweetId: { eq: tweetId } },
sort: "insertedAt",
headers: buildCSRFHeaders(),
});
if (!res.success) throw new Error("Failed to load comments");
const results = Array.isArray(res.data) ? res.data : (res.data as any)?.results ?? [];
return results as Tweet[];
},
});
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
const res = await destroyTweet({ identity: tweetId, headers: buildCSRFHeaders() }); const res = await destroyTweet({ identity: tweetId, headers: buildCSRFHeaders() });
@@ -859,16 +979,142 @@ function TweetDetail({ tweetId }: { tweetId: string }) {
</svg> </svg>
<span>{tweet.likes}</span> <span>{tweet.likes}</span>
</button> </button>
<span className="mx-like-btn mx-comment-count-badge" style={{ cursor: "default" }}>
<CommentIcon />
<span>{tweet.commentCount ?? 0} {(tweet.commentCount ?? 0) === 1 ? "reply" : "replies"}</span>
</span>
</div> </div>
{error && !editing && <p className="mx-compose-error">{error}</p>} {error && !editing && <p className="mx-compose-error">{error}</p>}
</div> </div>
{lightboxItem && <MediaLightbox item={lightboxItem} onClose={() => setLightboxItem(null)} />} {lightboxItem && <MediaLightbox item={lightboxItem} onClose={() => setLightboxItem(null)} />}
{/* ── Comments section ── */}
<div className="mx-comments-section">
<div className="mx-comments-divider">
<span>Replies</span>
</div>
{email ? (
<ComposeComment parentTweetId={tweetId} />
) : (
<div className="mx-signin-cta mx-signin-cta--sm">
<p><a href="/sign-in" style={{ color: "var(--mx-accent)", textDecoration: "none" }}>Sign in</a> to reply.</p>
</div>
)}
{commentsLoading ? (
<Spinner />
) : comments && comments.length > 0 ? (
<div className="mx-comments-list">
{comments.map((c) => (
<CommentCard key={c.id} comment={c} />
))}
</div>
) : (
<div className="mx-empty mx-empty--sm">
<p className="mx-empty-sub">No replies yet. Be the first!</p>
</div>
)}
</div>
</div> </div>
); );
} }
function CommentCard({ comment }: { comment: Tweet }) {
const { userId: currentUserId } = useContext(AuthCtx);
const canLike = !!currentUserId;
const canModify = !!currentUserId && comment.userId === currentUserId;
const [confirmDelete, setConfirmDelete] = useState(false);
const [error, setError] = useState<string | null>(null);
const qc = useQueryClient();
const deleteMutation = useMutation({
mutationFn: async () => {
const res = await destroyTweet({ identity: comment.id, headers: buildCSRFHeaders() });
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to delete");
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["comments", comment.parentTweetId] });
qc.invalidateQueries({ queryKey: ["tweet", comment.parentTweetId] });
qc.invalidateQueries({ queryKey: ["tweets"] });
qc.invalidateQueries({ queryKey: ["following_tweets"] });
},
onError: (e: Error) => setError(e.message),
});
const likeMutation = useMutation({
mutationFn: async () => {
const action = comment.likedByMe ? unlikeTweet : likeTweet;
const res = await action({ identity: comment.id, fields: ["id", "likes", "likedByMe"], headers: buildCSRFHeaders() });
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed");
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["comments", comment.parentTweetId] }),
onError: (e: Error) => setError(e.message),
});
return (
<article className="mx-tweet mx-comment">
<div className="mx-tweet-avatar mx-tweet-avatar--sm">
<span>M</span>
</div>
<div className="mx-tweet-body">
<div className="mx-tweet-header">
<span className="mx-tweet-handle">{comment.userEmail ?? "@mixer"}</span>
<span className="mx-tweet-dot">·</span>
<span className="mx-tweet-time" title={comment.insertedAt ? new Date(comment.insertedAt).toLocaleString() : undefined}>{timeAgo(comment.insertedAt)}</span>
{canModify && (
<div className="mx-tweet-actions">
<button
className={`mx-action-btn mx-action-delete${confirmDelete ? " mx-action-confirm" : ""}`}
title={confirmDelete ? "Confirm delete" : "Delete"}
onClick={(e) => {
e.stopPropagation();
if (!confirmDelete) {
setConfirmDelete(true);
setTimeout(() => setConfirmDelete(false), 3000);
} else {
deleteMutation.mutate();
}
}}
>
{deleteMutation.isPending ? (
<span style={{ fontSize: "0.65rem" }}></span>
) : confirmDelete ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
</svg>
)}
</button>
</div>
)}
</div>
<p className="mx-tweet-text">{comment.content}</p>
{comment.media && comment.media.length > 0 && <TweetMedia media={comment.media} />}
<div className="mx-tweet-footer">
<button
className={`mx-like-btn${comment.likedByMe ? " mx-like-btn-active" : ""}`}
onClick={() => likeMutation.mutate()}
disabled={!canLike || likeMutation.isPending}
title={canLike ? (comment.likedByMe ? "Remove like" : "Like reply") : "Sign in to like replies"}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12.1 21.35 10.55 19.93C5.4 15.27 2 12.19 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.69-3.4 6.77-8.55 11.44z" />
</svg>
<span>{comment.likes}</span>
</button>
</div>
{error && <p className="mx-compose-error">{error}</p>}
</div>
</article>
);
}
const FEED_PAGE_SIZE = 10; const FEED_PAGE_SIZE = 10;
function FollowingFeed() { function FollowingFeed() {
@@ -887,9 +1133,10 @@ function FollowingFeed() {
queryKey: ["following_tweets"], queryKey: ["following_tweets"],
queryFn: async ({ pageParam }: { pageParam: number }) => { queryFn: async ({ pageParam }: { pageParam: number }) => {
const res = await readFollowingFeed({ const res = await readFollowingFeed({
fields: ["id", "content", "likes", "likedByMe", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }], fields: ["id", "content", "likes", "likedByMe", "commentCount", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }],
sort: "-insertedAt", sort: "-insertedAt",
page: { limit: FEED_PAGE_SIZE, offset: pageParam }, page: { limit: FEED_PAGE_SIZE, offset: pageParam },
filter: { parentTweetId: { isNil: true } },
headers: buildCSRFHeaders(), headers: buildCSRFHeaders(),
}); });
if (!res.success) throw new Error("Failed to load following feed"); if (!res.success) throw new Error("Failed to load following feed");
@@ -980,9 +1227,10 @@ function Feed() {
queryKey: ["tweets"], queryKey: ["tweets"],
queryFn: async ({ pageParam }: { pageParam: number }) => { queryFn: async ({ pageParam }: { pageParam: number }) => {
const res = await readTweet({ const res = await readTweet({
fields: ["id", "content", "likes", "likedByMe", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }], fields: ["id", "content", "likes", "likedByMe", "commentCount", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }],
sort: "-insertedAt", sort: "-insertedAt",
page: { limit: FEED_PAGE_SIZE, offset: pageParam }, page: { limit: FEED_PAGE_SIZE, offset: pageParam },
filter: { parentTweetId: { isNil: true } },
headers: buildCSRFHeaders(), headers: buildCSRFHeaders(),
}); });
if (!res.success) throw new Error("Failed to load tweets"); if (!res.success) throw new Error("Failed to load tweets");

View File

@@ -12,6 +12,10 @@ defmodule Mixer.Posts.Tweet do
postgres do postgres do
table "tweets" table "tweets"
repo Mixer.Repo repo Mixer.Repo
references do
reference :parent_tweet, on_delete: :delete
end
end end
state_machine do state_machine do
@@ -39,7 +43,7 @@ defmodule Mixer.Posts.Tweet do
create :create do create :create do
upsert? true upsert? true
accept [:content] accept [:content, :parent_tweet_id]
argument :media_id, :uuid, allow_nil?: true argument :media_id, :uuid, allow_nil?: true
change relate_actor(:user) change relate_actor(:user)
change transition_state(:posted) change transition_state(:posted)
@@ -181,6 +185,18 @@ defmodule Mixer.Posts.Tweet do
public? true public? true
end 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 has_many :media, Mixer.Posts.Media do
public? true public? true
end end
@@ -195,6 +211,10 @@ defmodule Mixer.Posts.Tweet do
end end
aggregates do aggregates do
count :comment_count, :comments do
public? true
end
exists :liked_by_me, :tweet_likes do exists :liked_by_me, :tweet_likes do
public? true public? true
filter expr(user_id == ^actor(:id)) filter expr(user_id == ^actor(:id))

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