diff --git a/adr/2023-11-26-shortform-data-model.md b/adr/2023-11-26-shortform-data-model.md index e3aa1a1..df95923 100644 --- a/adr/2023-11-26-shortform-data-model.md +++ b/adr/2023-11-26-shortform-data-model.md @@ -32,17 +32,17 @@ Comments may only include text. ## Relational Data Model -- [Table: `content`](#table-content) +- [Table: `posts`](#table-posts) - [Table: `comments`](#table-comments) - [Table: `assets`](#table-assets) - [Table: `tags`](#table-tags) -- [Table: `content_tags`](#table-content_tags) +- [Table: `post_tags`](#table-post_tags) - [Table: `asset_tags`](#table-asset_tags) -### Table: `content` +### Table: `posts` ```sql -CREATE TABLE content( +CREATE TABLE posts( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, external_id UUID NOT NULL UNIQUE, created_at TIMESTAMPTZ NOT NULL, @@ -58,16 +58,18 @@ CREATE TABLE content( CREATE TABLE comments( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, external_id UUID NOT NULL UNIQUE, + post_id BIGINT NOT NULL, created_at TIMESTAMPTZ NOT NULL, created_by BIGINT NOT NULL, - content TEXT NOT NULL, + contents TEXT NOT NULL, + depth INT NOT NULL, parent BIGINT NULL, ); ``` Comments store their content directly. Comments are not complex, and are -typically shorter than primary content. The practical limit for PostgreSQL is -1gb per row, but ShortForm imposes a configurable limit (10kb by default). +typically shorter than posts. The practical limit for PostgreSQL is 1gb per row, +but ShortForm imposes a configurable limit (10kb by default). ### Table: `assets` @@ -95,13 +97,13 @@ CREATE TABLE tags( Tags are arbitrary labels that authors may assign to top level posts and assets. -### Table: `content_tags` +### Table: `post_tags` ```sql -CREATE TABLE content_tags( - content_id BIGINT NOT NULL, +CREATE TABLE post_tags( + post_id BIGINT NOT NULL, tag_id BIGINT NOT NULL, - PRIMARY KEY(content_id, tag_id) + PRIMARY KEY(post_id, tag_id) ); ``` diff --git a/adr/2023-11-27-shortform-user-model.md b/adr/2023-11-27-shortform-user-model.md index 6087d96..2d5855a 100644 --- a/adr/2023-11-27-shortform-user-model.md +++ b/adr/2023-11-27-shortform-user-model.md @@ -53,11 +53,11 @@ CREATE TYPE user_status AS ENUM ('active', 'locked', 'initializing'); ```sql CREATE TABLE users( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - created_at TIMESTAMPTZ NOT NULL, username TEXT NOT NULL UNIQUE, password TEXT NOT NULL, role user_role NOT NULL, - status user_status NOT NULL + status user_status NOT NULL, + created_at TIMESTAMPTZ NOT NULL ); ``` @@ -66,9 +66,9 @@ CREATE TABLE users( ```sql CREATE TABLE password_resets( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + token TEXT NOT NULL UNIQUE, created_at TIMESTAMPTZ NOT NULL, expires_at TIMESTAMPTZ NOT NULL, - token TEXT NOT NULL, used BOOLEAN NOT NULL ); ``` diff --git a/modules/model/src/main/scala/gs/shortform/model/Comment.scala b/modules/model/src/main/scala/gs/shortform/model/Comment.scala new file mode 100644 index 0000000..486756a --- /dev/null +++ b/modules/model/src/main/scala/gs/shortform/model/Comment.scala @@ -0,0 +1,61 @@ +package gs.shortform.model + +import gs.uuid.UUID + +/** + * Represents a comment. All comments are associated to some [[Post]], but + * may also be replies to other comments. + * + * Comments cannot currently be edited. + * + * @param externalId Globally unique identifier for this comment. + * @param createdAt Instant this comment was created. + * @param createdBy User who created this comment. + * @param rendered The pre-rendered text contents of this comment. + * @param depth The depth of this comment. + * @param parent The parent comment of this comment. + */ +case class Comment( + externalId: UUID, + createdAt: CreatedAt, + createdBy: Username, + rendered: String, + depth: Comment.Depth, + parent: Option[UUID] +) + +object Comment: + + /** + * Represents comment depth. Used for rendition purposes. A depth of 0 + * represents a top-level comment on some post. + */ + opaque type Depth = Int + + object Depth: + + /** + * Instantiate a new [[Depth]], forcing a minimum value of 0. + * + * @param value The value, rounded up to 0 if negative. + * @return The new [[Depth]]. + */ + def apply(value: Int): Depth = + if value < 0 then 0 else value + + given CanEqual[Depth, Depth] = CanEqual.derived + + extension (depth: Depth) + /** + * @return The integer value of this depth. + */ + def toInt(): Int = depth + + /** + * @return New [[Depth]] that is one level deeper. + */ + def increment(): Depth = toInt() + 1 + + end Depth + +end Comment diff --git a/modules/model/src/main/scala/gs/shortform/model/Content.scala b/modules/model/src/main/scala/gs/shortform/model/Content.scala deleted file mode 100644 index cdecb10..0000000 --- a/modules/model/src/main/scala/gs/shortform/model/Content.scala +++ /dev/null @@ -1,25 +0,0 @@ -package gs.shortform.model - -import gs.uuid.UUID -import gs.shortform.crypto.Hash - -/** - * Represents a top level piece of content - an article, essay, prompt, or some - * other written piece of work. All content must have a [[Title]] for display - * purposes. - * - * Content cannot currently be edited. - * - * @param externalId Globally unique identifier for this content. - * @param createdAt Instant this content was created. - * @param createdBy User who created this content. - * @param title Display title for this content. - * @param hash Hash of the primary rendered file for this content. - */ -case class Content( - externalId: UUID, - createdAt: CreatedAt, - createdBy: Username, - title: Title, - hash: Hash -) diff --git a/modules/model/src/main/scala/gs/shortform/model/ExpiresAt.scala b/modules/model/src/main/scala/gs/shortform/model/ExpiresAt.scala new file mode 100644 index 0000000..0306031 --- /dev/null +++ b/modules/model/src/main/scala/gs/shortform/model/ExpiresAt.scala @@ -0,0 +1,33 @@ +package gs.shortform.model + +import java.time.Instant +import java.time.OffsetDateTime +import java.time.ZoneOffset + +/** + * Represents the instant some resource expires. + */ +opaque type ExpiresAt = Instant + +object ExpiresAt: + + def apply(value: Instant): ExpiresAt = value + + def fromOffsetDateTime(value: OffsetDateTime): ExpiresAt = value.toInstant() + + given CanEqual[ExpiresAt, ExpiresAt] = CanEqual.derived + + extension (expiresAt: ExpiresAt) + /** + * Convert this value to an `Instant`. + */ + def toInstant(): Instant = expiresAt + + /** + * Convert this value to an `OffsetDateTime` with the UTC offset. + */ + def toOffsetDateTime(): OffsetDateTime = + toInstant().atOffset(ZoneOffset.UTC) + +end ExpiresAt + diff --git a/modules/model/src/main/scala/gs/shortform/model/PasswordReset.scala b/modules/model/src/main/scala/gs/shortform/model/PasswordReset.scala new file mode 100644 index 0000000..80337d5 --- /dev/null +++ b/modules/model/src/main/scala/gs/shortform/model/PasswordReset.scala @@ -0,0 +1,17 @@ +package gs.shortform.model + +/** + * Represents a _password reset token_ that a user can use to set a new + * password. + * + * @param token The unique token. + * @param used Whether this token has been used. + * @param createdAt The instant this token was created. + * @param expiresAt The instant this token expires. + */ +case class PasswordReset( + token: String, + used: Boolean, + createdAt: CreatedAt, + expiresAt: ExpiresAt +) diff --git a/modules/model/src/main/scala/gs/shortform/model/Post.scala b/modules/model/src/main/scala/gs/shortform/model/Post.scala new file mode 100644 index 0000000..5fc30d6 --- /dev/null +++ b/modules/model/src/main/scala/gs/shortform/model/Post.scala @@ -0,0 +1,25 @@ +package gs.shortform.model + +import gs.uuid.UUID +import gs.shortform.crypto.Hash + +/** + * Represents a top level piece of content - an article, essay, prompt, or some + * other written piece of work. All posts must have a [[Title]] for display + * purposes. + * + * Posts cannot currently be edited. + * + * @param externalId Globally unique identifier for this post. + * @param createdAt Instant this post was created. + * @param createdBy User who created this post. + * @param title Display title for this post. + * @param hash Hash of the primary rendered file for this post. + */ +case class Post( + externalId: UUID, + createdAt: CreatedAt, + createdBy: Username, + title: Title, + hash: Hash +)