diff --git a/.env.example b/.env.example index eb9c21b..71b5d1a 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ -PORT=9000 -HOST=localhost +APP_PORT=9000 +APP_DOMAIN=localhost +APP_ENV=development DB_URL=jdbc:postgresql://localhost:5432/homepage DB_USERNAME=postgres diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 428dfd3..68fca5a 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -21,7 +21,7 @@ jobs: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres ports: - - 5445:5432 + - 5432:5432 options: >- --health-cmd="pg_isready -U postgres" --health-interval=5s @@ -39,6 +39,8 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + with: + driver-opts: network=host - name: Log in to GitHub Container Registry uses: docker/login-action@v3 @@ -60,24 +62,29 @@ jobs: type=sha,prefix={{branch}}- type=raw,value=latest,enable={{is_default_branch}} - - name: Wait for PostgreSQL - run: | - until pg_isready -h localhost -p 5445 -U postgres; do - echo "Waiting for postgres..." - sleep 2 - done - - name: Build and publish Docker image uses: docker/build-push-action@v5 with: context: . push: true - network: host - build-args: | - DB_URL=jdbc:postgresql://localhost:5445/homepage - DB_USERNAME=postgres - DB_PASSWORD=postgres tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max + + deploy: + runs-on: ubuntu-latest + needs: build + if: success() + + steps: + - name: Trigger Dokploy deployment + run: | + curl -X 'POST' \ + 'http://5.180.253.47:3000/api/application.deploy' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -H 'x-api-key: ${{ secrets.DOKPLOY_API_KEY }}' \ + -d '{ + "applicationId": "${{ vars.DOKPLOY_APP_ID }}" + }' \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0e0ad97..1fee1ef 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,10 @@ gradle-app.setting # JDT-specific (Eclipse Java Development Tools) .classpath +# Allow generated code fragments for Docker builds +!build/generated-src/** +!build/generated-resources/** + ################### ### Environment ### ################### diff --git a/Dockerfile b/Dockerfile index 8afe2ae..8e2c09b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,6 @@ # --- Stage 1: Build the JAR --- FROM gradle:9.2.1-jdk21 AS build -ARG DB_URL -ARG DB_USERNAME -ARG DB_PASSWORD -ENV DB_URL=${DB_URL} -ENV DB_USERNAME=${DB_USERNAME} -ENV DB_PASSWORD=${DB_PASSWORD} - # Set working dir WORKDIR /app @@ -18,8 +11,12 @@ COPY --chown=gradle:gradle gradle ./gradle # Copy source code COPY --chown=gradle:gradle src ./src -# Build the fat jar -RUN ./gradlew clean build --no-daemon +# Copy pre-generated code fragments +COPY --chown=gradle:gradle build/generated-src ./build/generated-src +COPY --chown=gradle:gradle build/generated-resources ./build/generated-resources + +# Build the fat jar without cleaning (preserves generated code) +RUN ./gradlew build -x clean -x cleanGenerated -x jooqCodegen -x flywayMigrate -x precompileJte --no-daemon # --- Stage 2: Run the app --- FROM eclipse-temurin:21-jdk-alpine diff --git a/build.gradle.kts b/build.gradle.kts index 2181e03..defbd59 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -269,10 +269,15 @@ tasks.named("shadowJar") { archiveFileName.set("app.jar") - mergeServiceFiles() + mergeServiceFiles { + include("META-INF/services/**") + + // Fix: https://github.com/flyway/flyway/issues/4170#issuecomment-3569762563 + duplicatesStrategy = DuplicatesStrategy.INCLUDE + } exclude( - "META-INF/*. RSA", + "META-INF/*.RSA", "META-INF/*.SF", "META-INF/*.DSA" ) @@ -295,8 +300,4 @@ tasks.register("cleanGenerated") { delete(generatedSourcesDir) logger.lifecycle("✓ Cleaned generated code directories") } -} - -tasks.named("clean") { - dependsOn("cleanGenerated") } \ No newline at end of file diff --git a/build/generated-resources/jte/META-INF/main.kotlin_module b/build/generated-resources/jte/META-INF/main.kotlin_module new file mode 100644 index 0000000..9dbc290 Binary files /dev/null and b/build/generated-resources/jte/META-INF/main.kotlin_module differ diff --git a/build/generated-resources/jte/gg/jte/generated/precompiled/JteIndexGenerated$Companion.class b/build/generated-resources/jte/gg/jte/generated/precompiled/JteIndexGenerated$Companion.class new file mode 100644 index 0000000..448574b Binary files /dev/null and b/build/generated-resources/jte/gg/jte/generated/precompiled/JteIndexGenerated$Companion.class differ diff --git a/build/generated-resources/jte/gg/jte/generated/precompiled/JteIndexGenerated.class b/build/generated-resources/jte/gg/jte/generated/precompiled/JteIndexGenerated.class new file mode 100644 index 0000000..4c47fad Binary files /dev/null and b/build/generated-resources/jte/gg/jte/generated/precompiled/JteIndexGenerated.class differ diff --git a/build/generated-resources/jte/gg/jte/generated/precompiled/JteIndexGenerated.kt b/build/generated-resources/jte/gg/jte/generated/precompiled/JteIndexGenerated.kt new file mode 100644 index 0000000..5b8c1dc --- /dev/null +++ b/build/generated-resources/jte/gg/jte/generated/precompiled/JteIndexGenerated.kt @@ -0,0 +1,35 @@ +@file:Suppress("ktlint") +package gg.jte.generated.precompiled +import at.dokkae.homepage.templates.IndexTemplate +import at.dokkae.homepage.templates.MessageTemplate +import gg.jte.support.ForSupport +@Suppress("UNCHECKED_CAST", "UNUSED_PARAMETER") +class JteIndexGenerated { +companion object { + @JvmField val JTE_NAME = "Index.kte" + @JvmField val JTE_LINE_INFO = intArrayOf(0,0,0,1,2,4,4,4,4,4,18,18,37,58,88,93,96,96,97,97,98,98,102,108,119,132,146,161,161,161,4,4,4,4,4) + @JvmStatic fun render(jteOutput:gg.jte.html.HtmlTemplateOutput, jteHtmlInterceptor:gg.jte.html.HtmlInterceptor?, model:IndexTemplate) { + jteOutput.writeContent("\n\n\n\n \n \n Dokkae's Chat\n\n \n \n \n\n \n\n\n
\n ") + jteOutput.writeContent("\n
\n

Dokkae's Chat

\n
\n\n ") + jteOutput.writeContent("\n
\n
\n ") + for (message in model.messages) { + jteOutput.writeContent("\n ") + gg.jte.generated.precompiled.partials.JteMessageGenerated.render(jteOutput, jteHtmlInterceptor, MessageTemplate(message)); + jteOutput.writeContent("\n ") + } + jteOutput.writeContent("\n
\n
\n\n ") + jteOutput.writeContent("\n
\n
\n ") + jteOutput.writeContent("\n
\n
\n \n
\n
\n\n ") + jteOutput.writeContent("\n
\n
\n
\n \n
\n\n ") + jteOutput.writeContent("\n \n
\n
\n
\n
\n\n ") + jteOutput.writeContent("\n \n
\n\n\n") + } + @JvmStatic fun renderMap(jteOutput:gg.jte.html.HtmlTemplateOutput, jteHtmlInterceptor:gg.jte.html.HtmlInterceptor?, params:Map) { + val model = params["model"] as IndexTemplate + render(jteOutput, jteHtmlInterceptor, model); + } +} +} diff --git a/build/generated-resources/jte/gg/jte/generated/precompiled/partials/JteMessageGenerated$Companion.class b/build/generated-resources/jte/gg/jte/generated/precompiled/partials/JteMessageGenerated$Companion.class new file mode 100644 index 0000000..356b8f6 Binary files /dev/null and b/build/generated-resources/jte/gg/jte/generated/precompiled/partials/JteMessageGenerated$Companion.class differ diff --git a/build/generated-resources/jte/gg/jte/generated/precompiled/partials/JteMessageGenerated.class b/build/generated-resources/jte/gg/jte/generated/precompiled/partials/JteMessageGenerated.class new file mode 100644 index 0000000..a51102d Binary files /dev/null and b/build/generated-resources/jte/gg/jte/generated/precompiled/partials/JteMessageGenerated.class differ diff --git a/build/generated-resources/jte/gg/jte/generated/precompiled/partials/JteMessageGenerated.kt b/build/generated-resources/jte/gg/jte/generated/precompiled/partials/JteMessageGenerated.kt new file mode 100644 index 0000000..f00a90c --- /dev/null +++ b/build/generated-resources/jte/gg/jte/generated/precompiled/partials/JteMessageGenerated.kt @@ -0,0 +1,46 @@ +@file:Suppress("ktlint") +package gg.jte.generated.precompiled.partials +import at.dokkae.homepage.templates.MessageTemplate +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import kotlin.math.absoluteValue +@Suppress("UNCHECKED_CAST", "UNUSED_PARAMETER") +class JteMessageGenerated { +companion object { + @JvmField val JTE_NAME = "partials/Message.kte" + @JvmField val JTE_LINE_INFO = intArrayOf(0,0,0,1,2,3,4,6,6,6,6,6,8,8,8,9,9,10,10,14,15,15,15,15,18,21,21,21,24,24,24,24,24,24,28,30,30,30,34,34,34,6,6,6,6,6) + @JvmStatic fun render(jteOutput:gg.jte.html.HtmlTemplateOutput, jteHtmlInterceptor:gg.jte.html.HtmlInterceptor?, model:MessageTemplate) { + jteOutput.writeContent("\n") + val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy").withZone(ZoneId.systemDefault()) + jteOutput.writeContent("\n") + val timeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault()) + jteOutput.writeContent("\n") + val borderColors = listOf("red", "orange", "yellow", "green", "blue", "pink" ) + jteOutput.writeContent("\n\n
\n
\n ") + jteOutput.writeContent("\n
\n\n
\n ") + jteOutput.writeContent("\n
\n \n ") + jteOutput.setContext("span", null) + jteOutput.writeUserContent(model.message.author) + jteOutput.writeContent("\n \n \n ") + jteOutput.setContext("span", null) + jteOutput.writeUserContent(dateFormatter.format(model.message.createdAt)) + jteOutput.writeContent(" • ") + jteOutput.setContext("span", null) + jteOutput.writeUserContent(timeFormatter.format(model.message.createdAt)) + jteOutput.writeContent("\n \n
\n\n ") + jteOutput.writeContent("\n
\n ") + jteOutput.setContext("div", null) + jteOutput.writeUserContent(model.message.content) + jteOutput.writeContent("\n
\n
\n
\n
") + } + @JvmStatic fun renderMap(jteOutput:gg.jte.html.HtmlTemplateOutput, jteHtmlInterceptor:gg.jte.html.HtmlInterceptor?, params:Map) { + val model = params["model"] as MessageTemplate + render(jteOutput, jteHtmlInterceptor, model); + } +} +} diff --git a/build/generated-src/jooq/at/dokkae/homepage/generated/jooq/DefaultCatalog.kt b/build/generated-src/jooq/at/dokkae/homepage/generated/jooq/DefaultCatalog.kt new file mode 100644 index 0000000..034f3de --- /dev/null +++ b/build/generated-src/jooq/at/dokkae/homepage/generated/jooq/DefaultCatalog.kt @@ -0,0 +1,43 @@ +/* + * This file is generated by jOOQ. + */ +package at.dokkae.homepage.generated.jooq + + +import kotlin.collections.List + +import org.jooq.Constants +import org.jooq.Schema +import org.jooq.impl.CatalogImpl + + +/** + * This class is generated by jOOQ. + */ +@Suppress("warnings") +open class DefaultCatalog : CatalogImpl("") { + companion object { + + /** + * The reference instance of DEFAULT_CATALOG + */ + val DEFAULT_CATALOG: DefaultCatalog = DefaultCatalog() + } + + /** + * standard public schema + */ + val PUBLIC: Public get(): Public = Public.PUBLIC + + override fun getSchemas(): List = listOf( + Public.PUBLIC + ) + + /** + * A reference to the 3.20 minor release of the code generator. If this + * doesn't compile, it's because the runtime library uses an older minor + * release, namely: 3.20. You can turn off the generation of this reference + * by specifying /configuration/generator/generate/jooqVersionReference + */ + private val REQUIRE_RUNTIME_JOOQ_VERSION = Constants.VERSION_3_20 +} diff --git a/build/generated-src/jooq/at/dokkae/homepage/generated/jooq/Public.kt b/build/generated-src/jooq/at/dokkae/homepage/generated/jooq/Public.kt new file mode 100644 index 0000000..772d0e4 --- /dev/null +++ b/build/generated-src/jooq/at/dokkae/homepage/generated/jooq/Public.kt @@ -0,0 +1,40 @@ +/* + * This file is generated by jOOQ. + */ +package at.dokkae.homepage.generated.jooq + + +import at.dokkae.homepage.generated.jooq.tables.Message + +import kotlin.collections.List + +import org.jooq.Catalog +import org.jooq.Table +import org.jooq.impl.DSL +import org.jooq.impl.SchemaImpl + + +/** + * standard public schema + */ +@Suppress("warnings") +open class Public : SchemaImpl(DSL.name("public"), DefaultCatalog.DEFAULT_CATALOG, DSL.comment("standard public schema")) { + companion object { + + /** + * The reference instance of public + */ + val PUBLIC: Public = Public() + } + + /** + * The table public.message. + */ + val MESSAGE: Message get() = Message.MESSAGE + + override fun getCatalog(): Catalog = DefaultCatalog.DEFAULT_CATALOG + + override fun getTables(): List> = listOf( + Message.MESSAGE + ) +} diff --git a/build/generated-src/jooq/at/dokkae/homepage/generated/jooq/keys/Keys.kt b/build/generated-src/jooq/at/dokkae/homepage/generated/jooq/keys/Keys.kt new file mode 100644 index 0000000..f59d310 --- /dev/null +++ b/build/generated-src/jooq/at/dokkae/homepage/generated/jooq/keys/Keys.kt @@ -0,0 +1,21 @@ +@file:Suppress("warnings") +/* + * This file is generated by jOOQ. + */ +package at.dokkae.homepage.generated.jooq.keys + + +import at.dokkae.homepage.generated.jooq.tables.Message +import at.dokkae.homepage.generated.jooq.tables.records.MessageRecord + +import org.jooq.UniqueKey +import org.jooq.impl.DSL +import org.jooq.impl.Internal + + + +// ------------------------------------------------------------------------- +// UNIQUE and PRIMARY KEY definitions +// ------------------------------------------------------------------------- + +val MESSAGE_PKEY: UniqueKey = Internal.createUniqueKey(Message.MESSAGE, DSL.name("message_pkey"), arrayOf(Message.MESSAGE.ID), true) diff --git a/build/generated-src/jooq/at/dokkae/homepage/generated/jooq/tables/Message.kt b/build/generated-src/jooq/at/dokkae/homepage/generated/jooq/tables/Message.kt new file mode 100644 index 0000000..4801187 --- /dev/null +++ b/build/generated-src/jooq/at/dokkae/homepage/generated/jooq/tables/Message.kt @@ -0,0 +1,187 @@ +/* + * This file is generated by jOOQ. + */ +package at.dokkae.homepage.generated.jooq.tables + + +import at.dokkae.homepage.generated.jooq.Public +import at.dokkae.homepage.generated.jooq.keys.MESSAGE_PKEY +import at.dokkae.homepage.generated.jooq.tables.records.MessageRecord + +import java.time.OffsetDateTime +import java.util.UUID + +import kotlin.collections.Collection + +import org.jooq.Condition +import org.jooq.Field +import org.jooq.ForeignKey +import org.jooq.InverseForeignKey +import org.jooq.Name +import org.jooq.PlainSQL +import org.jooq.QueryPart +import org.jooq.Record +import org.jooq.SQL +import org.jooq.Schema +import org.jooq.Select +import org.jooq.Stringly +import org.jooq.Table +import org.jooq.TableField +import org.jooq.TableOptions +import org.jooq.UniqueKey +import org.jooq.impl.DSL +import org.jooq.impl.SQLDataType +import org.jooq.impl.TableImpl + + +/** + * This class is generated by jOOQ. + */ +@Suppress("warnings") +open class Message( + alias: Name, + path: Table?, + childPath: ForeignKey?, + parentPath: InverseForeignKey?, + aliased: Table?, + parameters: Array?>?, + where: Condition? +): TableImpl( + alias, + Public.PUBLIC, + path, + childPath, + parentPath, + aliased, + parameters, + DSL.comment(""), + TableOptions.table(), + where, +) { + companion object { + + /** + * The reference instance of public.message + */ + val MESSAGE: Message = Message() + } + + /** + * The class holding records for this type + */ + override fun getRecordType(): Class = MessageRecord::class.java + + /** + * The column public.message.id. + */ + val ID: TableField = createField(DSL.name("id"), SQLDataType.UUID.nullable(false), this, "") + + /** + * The column public.message.author. + */ + val AUTHOR: TableField = createField(DSL.name("author"), SQLDataType.VARCHAR(31).nullable(false), this, "") + + /** + * The column public.message.content. + */ + val CONTENT: TableField = createField(DSL.name("content"), SQLDataType.VARCHAR(255).nullable(false), this, "") + + /** + * The column public.message.created_at. + */ + val CREATED_AT: TableField = createField(DSL.name("created_at"), SQLDataType.TIMESTAMPWITHTIMEZONE(6).nullable(false).defaultValue(DSL.field(DSL.raw("CURRENT_TIMESTAMP"), SQLDataType.TIMESTAMPWITHTIMEZONE)), this, "") + + /** + * The column public.message.updated_at. + */ + val UPDATED_AT: TableField = createField(DSL.name("updated_at"), SQLDataType.TIMESTAMPWITHTIMEZONE(6), this, "") + + private constructor(alias: Name, aliased: Table?): this(alias, null, null, null, aliased, null, null) + private constructor(alias: Name, aliased: Table?, parameters: Array?>?): this(alias, null, null, null, aliased, parameters, null) + private constructor(alias: Name, aliased: Table?, where: Condition?): this(alias, null, null, null, aliased, null, where) + + /** + * Create an aliased public.message table reference + */ + constructor(alias: String): this(DSL.name(alias)) + + /** + * Create an aliased public.message table reference + */ + constructor(alias: Name): this(alias, null) + + /** + * Create a public.message table reference + */ + constructor(): this(DSL.name("message"), null) + override fun getSchema(): Schema? = if (aliased()) null else Public.PUBLIC + override fun getPrimaryKey(): UniqueKey = MESSAGE_PKEY + override fun `as`(alias: String): Message = Message(DSL.name(alias), this) + override fun `as`(alias: Name): Message = Message(alias, this) + override fun `as`(alias: Table<*>): Message = Message(alias.qualifiedName, this) + + /** + * Rename this table + */ + override fun rename(name: String): Message = Message(DSL.name(name), null) + + /** + * Rename this table + */ + override fun rename(name: Name): Message = Message(name, null) + + /** + * Rename this table + */ + override fun rename(name: Table<*>): Message = Message(name.qualifiedName, null) + + /** + * Create an inline derived table from this table + */ + override fun where(condition: Condition?): Message = Message(qualifiedName, if (aliased()) this else null, condition) + + /** + * Create an inline derived table from this table + */ + override fun where(conditions: Collection): Message = where(DSL.and(conditions)) + + /** + * Create an inline derived table from this table + */ + override fun where(vararg conditions: Condition?): Message = where(DSL.and(*conditions)) + + /** + * Create an inline derived table from this table + */ + override fun where(condition: Field?): Message = where(DSL.condition(condition)) + + /** + * Create an inline derived table from this table + */ + @PlainSQL override fun where(condition: SQL): Message = where(DSL.condition(condition)) + + /** + * Create an inline derived table from this table + */ + @PlainSQL override fun where(@Stringly.SQL condition: String): Message = where(DSL.condition(condition)) + + /** + * Create an inline derived table from this table + */ + @PlainSQL override fun where(@Stringly.SQL condition: String, vararg binds: Any?): Message = where(DSL.condition(condition, *binds)) + + /** + * Create an inline derived table from this table + */ + @PlainSQL override fun where(@Stringly.SQL condition: String, vararg parts: QueryPart): Message = where(DSL.condition(condition, *parts)) + + /** + * Create an inline derived table from this table + */ + override fun whereExists(select: Select<*>): Message = where(DSL.exists(select)) + + /** + * Create an inline derived table from this table + */ + override fun whereNotExists(select: Select<*>): Message = where(DSL.notExists(select)) +} diff --git a/build/generated-src/jooq/at/dokkae/homepage/generated/jooq/tables/pojos/Message.kt b/build/generated-src/jooq/at/dokkae/homepage/generated/jooq/tables/pojos/Message.kt new file mode 100644 index 0000000..7976170 --- /dev/null +++ b/build/generated-src/jooq/at/dokkae/homepage/generated/jooq/tables/pojos/Message.kt @@ -0,0 +1,76 @@ +/* + * This file is generated by jOOQ. + */ +package at.dokkae.homepage.generated.jooq.tables.pojos + + +import java.io.Serializable +import java.time.OffsetDateTime +import java.util.UUID + + +/** + * This class is generated by jOOQ. + */ +@Suppress("warnings") +data class Message( + val id: UUID, + val author: String, + val content: String, + val createdAt: OffsetDateTime? = null, + val updatedAt: OffsetDateTime? = null +): Serializable { + + override fun equals(other: Any?): Boolean { + if (this === other) + return true + if (other == null) + return false + if (this::class != other::class) + return false + val o: Message = other as Message + if (this.id != o.id) + return false + if (this.author != o.author) + return false + if (this.content != o.content) + return false + if (this.createdAt == null) { + if (o.createdAt != null) + return false + } + else if (this.createdAt != o.createdAt) + return false + if (this.updatedAt == null) { + if (o.updatedAt != null) + return false + } + else if (this.updatedAt != o.updatedAt) + return false + return true + } + + override fun hashCode(): Int { + val prime = 31 + var result = 1 + result = prime * result + this.id.hashCode() + result = prime * result + this.author.hashCode() + result = prime * result + this.content.hashCode() + result = prime * result + (if (this.createdAt == null) 0 else this.createdAt.hashCode()) + result = prime * result + (if (this.updatedAt == null) 0 else this.updatedAt.hashCode()) + return result + } + + override fun toString(): String { + val sb = StringBuilder("Message (") + + sb.append(id) + sb.append(", ").append(author) + sb.append(", ").append(content) + sb.append(", ").append(createdAt) + sb.append(", ").append(updatedAt) + + sb.append(")") + return sb.toString() + } +} diff --git a/build/generated-src/jooq/at/dokkae/homepage/generated/jooq/tables/records/MessageRecord.kt b/build/generated-src/jooq/at/dokkae/homepage/generated/jooq/tables/records/MessageRecord.kt new file mode 100644 index 0000000..fb107a6 --- /dev/null +++ b/build/generated-src/jooq/at/dokkae/homepage/generated/jooq/tables/records/MessageRecord.kt @@ -0,0 +1,73 @@ +/* + * This file is generated by jOOQ. + */ +package at.dokkae.homepage.generated.jooq.tables.records + + +import at.dokkae.homepage.generated.jooq.tables.Message + +import java.time.OffsetDateTime +import java.util.UUID + +import org.jooq.Record1 +import org.jooq.impl.UpdatableRecordImpl + + +/** + * This class is generated by jOOQ. + */ +@Suppress("warnings") +open class MessageRecord private constructor() : UpdatableRecordImpl(Message.MESSAGE) { + + open var id: UUID + set(value): Unit = set(0, value) + get(): UUID = get(0) as UUID + + open var author: String + set(value): Unit = set(1, value) + get(): String = get(1) as String + + open var content: String + set(value): Unit = set(2, value) + get(): String = get(2) as String + + open var createdAt: OffsetDateTime? + set(value): Unit = set(3, value) + get(): OffsetDateTime? = get(3) as OffsetDateTime? + + open var updatedAt: OffsetDateTime? + set(value): Unit = set(4, value) + get(): OffsetDateTime? = get(4) as OffsetDateTime? + + // ------------------------------------------------------------------------- + // Primary key information + // ------------------------------------------------------------------------- + + override fun key(): Record1 = super.key() as Record1 + + /** + * Create a detached, initialised MessageRecord + */ + constructor(id: UUID, author: String, content: String, createdAt: OffsetDateTime? = null, updatedAt: OffsetDateTime? = null): this() { + this.id = id + this.author = author + this.content = content + this.createdAt = createdAt + this.updatedAt = updatedAt + resetTouchedOnNotNull() + } + + /** + * Create a detached, initialised MessageRecord + */ + constructor(value: at.dokkae.homepage.generated.jooq.tables.pojos.Message?): this() { + if (value != null) { + this.id = value.id + this.author = value.author + this.content = value.content + this.createdAt = value.createdAt + this.updatedAt = value.updatedAt + resetTouchedOnNotNull() + } + } +} diff --git a/build/generated-src/jooq/at/dokkae/homepage/generated/jooq/tables/references/Tables.kt b/build/generated-src/jooq/at/dokkae/homepage/generated/jooq/tables/references/Tables.kt new file mode 100644 index 0000000..4feda24 --- /dev/null +++ b/build/generated-src/jooq/at/dokkae/homepage/generated/jooq/tables/references/Tables.kt @@ -0,0 +1,15 @@ +@file:Suppress("warnings") +/* + * This file is generated by jOOQ. + */ +package at.dokkae.homepage.generated.jooq.tables.references + + +import at.dokkae.homepage.generated.jooq.tables.Message + + + +/** + * The table public.message. + */ +val MESSAGE: Message = Message.MESSAGE diff --git a/jte-classes/META-INF/main.kotlin_module b/jte-classes/META-INF/main.kotlin_module new file mode 100644 index 0000000..9dbc290 Binary files /dev/null and b/jte-classes/META-INF/main.kotlin_module differ diff --git a/jte-classes/gg/jte/generated/ondemand/JteIndexGenerated.kt b/jte-classes/gg/jte/generated/ondemand/JteIndexGenerated.kt new file mode 100644 index 0000000..a762a23 --- /dev/null +++ b/jte-classes/gg/jte/generated/ondemand/JteIndexGenerated.kt @@ -0,0 +1,35 @@ +@file:Suppress("ktlint") +package gg.jte.generated.ondemand +import at.dokkae.homepage.templates.IndexTemplate +import at.dokkae.homepage.templates.MessageTemplate +import gg.jte.support.ForSupport +@Suppress("UNCHECKED_CAST", "UNUSED_PARAMETER") +class JteIndexGenerated { +companion object { + @JvmField val JTE_NAME = "Index.kte" + @JvmField val JTE_LINE_INFO = intArrayOf(0,0,0,1,2,4,4,4,4,4,18,18,37,58,88,93,96,96,97,97,98,98,102,108,119,132,146,161,161,161,4,4,4,4,4) + @JvmStatic fun render(jteOutput:gg.jte.html.HtmlTemplateOutput, jteHtmlInterceptor:gg.jte.html.HtmlInterceptor?, model:IndexTemplate) { + jteOutput.writeContent("\n\n\n\n \n \n Dokkae's Chat\n\n \n \n \n\n \n\n\n
\n ") + jteOutput.writeContent("\n
\n

Dokkae's Chat

\n
\n\n ") + jteOutput.writeContent("\n
\n
\n ") + for (message in model.messages) { + jteOutput.writeContent("\n ") + gg.jte.generated.ondemand.partials.JteMessageGenerated.render(jteOutput, jteHtmlInterceptor, MessageTemplate(message)); + jteOutput.writeContent("\n ") + } + jteOutput.writeContent("\n
\n
\n\n ") + jteOutput.writeContent("\n
\n
\n ") + jteOutput.writeContent("\n
\n
\n \n
\n
\n\n ") + jteOutput.writeContent("\n
\n
\n
\n \n
\n\n ") + jteOutput.writeContent("\n \n
\n
\n
\n
\n\n ") + jteOutput.writeContent("\n \n
\n\n\n") + } + @JvmStatic fun renderMap(jteOutput:gg.jte.html.HtmlTemplateOutput, jteHtmlInterceptor:gg.jte.html.HtmlInterceptor?, params:Map) { + val model = params["model"] as IndexTemplate + render(jteOutput, jteHtmlInterceptor, model); + } +} +} diff --git a/jte-classes/gg/jte/generated/ondemand/partials/JteMessageGenerated.kt b/jte-classes/gg/jte/generated/ondemand/partials/JteMessageGenerated.kt new file mode 100644 index 0000000..06f028d --- /dev/null +++ b/jte-classes/gg/jte/generated/ondemand/partials/JteMessageGenerated.kt @@ -0,0 +1,46 @@ +@file:Suppress("ktlint") +package gg.jte.generated.ondemand.partials +import at.dokkae.homepage.templates.MessageTemplate +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import kotlin.math.absoluteValue +@Suppress("UNCHECKED_CAST", "UNUSED_PARAMETER") +class JteMessageGenerated { +companion object { + @JvmField val JTE_NAME = "partials/Message.kte" + @JvmField val JTE_LINE_INFO = intArrayOf(0,0,0,1,2,3,4,6,6,6,6,6,8,8,8,9,9,10,10,14,15,15,15,15,18,21,21,21,24,24,24,24,24,24,28,30,30,30,34,34,34,6,6,6,6,6) + @JvmStatic fun render(jteOutput:gg.jte.html.HtmlTemplateOutput, jteHtmlInterceptor:gg.jte.html.HtmlInterceptor?, model:MessageTemplate) { + jteOutput.writeContent("\n") + val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy").withZone(ZoneId.systemDefault()) + jteOutput.writeContent("\n") + val timeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault()) + jteOutput.writeContent("\n") + val borderColors = listOf("red", "orange", "yellow", "green", "blue", "pink" ) + jteOutput.writeContent("\n\n
\n
\n ") + jteOutput.writeContent("\n
\n\n
\n ") + jteOutput.writeContent("\n
\n \n ") + jteOutput.setContext("span", null) + jteOutput.writeUserContent(model.message.author) + jteOutput.writeContent("\n \n \n ") + jteOutput.setContext("span", null) + jteOutput.writeUserContent(dateFormatter.format(model.message.createdAt)) + jteOutput.writeContent(" • ") + jteOutput.setContext("span", null) + jteOutput.writeUserContent(timeFormatter.format(model.message.createdAt)) + jteOutput.writeContent("\n \n
\n\n ") + jteOutput.writeContent("\n
\n ") + jteOutput.setContext("div", null) + jteOutput.writeUserContent(model.message.content) + jteOutput.writeContent("\n
\n
\n
\n
") + } + @JvmStatic fun renderMap(jteOutput:gg.jte.html.HtmlTemplateOutput, jteHtmlInterceptor:gg.jte.html.HtmlInterceptor?, params:Map) { + val model = params["model"] as MessageTemplate + render(jteOutput, jteHtmlInterceptor, model); + } +} +} diff --git a/src/main/kotlin/at/dokkae/homepage/Homepage.kt b/src/main/kotlin/at/dokkae/homepage/Homepage.kt index 3f910d9..636ad47 100644 --- a/src/main/kotlin/at/dokkae/homepage/Homepage.kt +++ b/src/main/kotlin/at/dokkae/homepage/Homepage.kt @@ -1,10 +1,12 @@ package at.dokkae.homepage +import at.dokkae.homepage.config.Env import at.dokkae.homepage.config.Environment import at.dokkae.homepage.extensions.Precompiled import at.dokkae.homepage.repository.MessageRepository import at.dokkae.homepage.repository.impls.JooqMessageRepository -import at.dokkae.homepage.templates.Index +import at.dokkae.homepage.templates.IndexTemplate +import at.dokkae.homepage.templates.MessageTemplate import io.github.cdimascio.dotenv.dotenv import org.flywaydb.core.Flyway import org.http4k.core.HttpHandler @@ -14,18 +16,19 @@ import org.http4k.core.Status import org.http4k.core.body.form import org.http4k.core.getFirst import org.http4k.core.toParametersMap +import org.http4k.routing.ResourceLoader import org.http4k.routing.bindHttp import org.http4k.routing.bindSse import org.http4k.routing.poly import org.http4k.routing.routes import org.http4k.routing.sse +import org.http4k.routing.static import org.http4k.server.Jetty import org.http4k.server.asServer import org.http4k.sse.Sse import org.http4k.sse.SseMessage import org.http4k.sse.SseResponse import org.http4k.template.JTETemplates -import org.http4k.template.ViewModel import org.jooq.SQLDialect import org.jooq.impl.DSL import java.sql.DriverManager @@ -52,14 +55,11 @@ data class Message( val id: UUID = UUID.randomUUID(), val createdAt: Instant = Instant.now(), val updatedAt: Instant? = null -) : ViewModel { +) { init { require(author.length <= 31) { "Author must be 31 characters or less" } require(content.length <= 255) { "Content must be 255 characters or less" } } - - - override fun template(): String = "partials/Message" } fun main() { @@ -77,10 +77,19 @@ fun main() { val messageRepository: MessageRepository = JooqMessageRepository(dslContext) val subscribers = CopyOnWriteArrayList() - val renderer = JTETemplates().Precompiled("build/generated-resources/jte") + val renderer = when (env.appEnv) { + Env.DEVELOPMENT -> { + println("🔥 Hot-Reloading JTE templates") + JTETemplates().HotReload("src/main/kte") + } + Env.PRODUCTION -> { + println("📦 Loading pre-compiled JTE templates") + JTETemplates().Precompiled("build/generated-resources/jte") + } + } val indexHandler: HttpHandler = { - Response(Status.OK).body(renderer(Index(messageRepository.findAll()))) + Response(Status.OK).body(renderer(IndexTemplate(messageRepository.findAll()))) } val sse = sse( @@ -94,30 +103,38 @@ fun main() { ) val http = routes( + static(ResourceLoader.Classpath("static")), + "/" bindHttp GET to indexHandler, "/messages" bindHttp POST to { req -> - val params = req.form().toParametersMap() - val author = params.getFirst("author").takeIf { !it.isNullOrBlank() } ?: "Anonymous" - val message = params.getFirst("message") + try { + val params = req.form().toParametersMap() + val author = params.getFirst("author").takeIf { !it.isNullOrBlank() } ?: "Anonymous" + val message = params.getFirst("message") - if (message == null) { - Response(Status.BAD_REQUEST) - } else { - val msg = Message(author, message) - val sseMsg = SseMessage.Data(renderer(msg)) + if (message == null) { + Response(Status.BAD_REQUEST) + } else { + val msg = Message(author, message) + val sseMsg = SseMessage.Data(renderer(MessageTemplate(msg))) - messageRepository.save(msg) - subscribers.forEach { - thread { it.send(sseMsg) } + messageRepository.save(msg) + subscribers.forEach { + thread { it.send(sseMsg) } + } + + Response(Status.CREATED) } + } catch (ex: Exception) { + println("Failed to receive message: ${ex.toString()} ${ex.message}") - Response(Status.CREATED) + Response(Status.INTERNAL_SERVER_ERROR) } } ) - poly(http, sse).asServer(Jetty(port = env.port)).start() + poly(http, sse).asServer(Jetty(port = env.appPort)).start() - println("Server started on http://${env.host}:${env.port}") + println("Server started on http://${env.appDomain}:${env.appPort}") } diff --git a/src/main/kotlin/at/dokkae/homepage/config/Environment.kt b/src/main/kotlin/at/dokkae/homepage/config/Environment.kt index 6321095..528c86e 100644 --- a/src/main/kotlin/at/dokkae/homepage/config/Environment.kt +++ b/src/main/kotlin/at/dokkae/homepage/config/Environment.kt @@ -2,9 +2,15 @@ package at.dokkae.homepage.config import io.github.cdimascio.dotenv.Dotenv +enum class Env { + DEVELOPMENT, + PRODUCTION, +} + data class Environment( - val port: Int, - val host: String, + val appPort: Int, + val appDomain: String, + val appEnv: Env, val dbUrl: String, val dbUsername: String, val dbPassword: String, @@ -16,8 +22,9 @@ data class Environment( * @throws IllegalStateException if required environment variables were not found within the provided `dotenv` instance. */ fun load(dotenv: Dotenv): Environment = Environment( - port = requireEnv(dotenv, "PORT").toInt(), - host = requireEnv(dotenv, "HOST"), + appPort = requireEnv(dotenv, "APP_PORT").toInt(), + appDomain = requireEnv(dotenv, "APP_DOMAIN"), + appEnv = Env.valueOf(requireEnv(dotenv, "APP_ENV").uppercase()), dbUrl = requireEnv(dotenv, "DB_URL"), dbUsername = requireEnv(dotenv, "DB_USERNAME"), dbPassword = requireEnv(dotenv, "DB_PASSWORD"), diff --git a/src/main/kotlin/at/dokkae/homepage/templates/Index.kt b/src/main/kotlin/at/dokkae/homepage/templates/Index.kt index 1258984..1e75cb7 100644 --- a/src/main/kotlin/at/dokkae/homepage/templates/Index.kt +++ b/src/main/kotlin/at/dokkae/homepage/templates/Index.kt @@ -3,6 +3,6 @@ package at.dokkae.homepage.templates import at.dokkae.homepage.Message import org.http4k.template.ViewModel -data class Index(val messages: List = listOf()) : ViewModel { +data class IndexTemplate(val messages: List = listOf()) : ViewModel { override fun template(): String = "Index" } \ No newline at end of file diff --git a/src/main/kotlin/at/dokkae/homepage/templates/Message.kt b/src/main/kotlin/at/dokkae/homepage/templates/Message.kt new file mode 100644 index 0000000..1b9eb18 --- /dev/null +++ b/src/main/kotlin/at/dokkae/homepage/templates/Message.kt @@ -0,0 +1,8 @@ +package at.dokkae.homepage.templates + +import at.dokkae.homepage.Message +import org.http4k.template.ViewModel + +data class MessageTemplate(val message: Message) : ViewModel { + override fun template(): String = "partials/Message" +} \ No newline at end of file diff --git a/src/main/kte/Index.kte b/src/main/kte/Index.kte index 08857e8..7b7933e 100644 --- a/src/main/kte/Index.kte +++ b/src/main/kte/Index.kte @@ -1,159 +1,162 @@ -@import at.dokkae.homepage.templates.Index +@import at.dokkae.homepage.templates.IndexTemplate +@import at.dokkae.homepage.templates.MessageTemplate +@import gg.jte.support.ForSupport -@param model: Index +@param model: IndexTemplate - Simple Chat — http4k + JTE + htmx + Dokkae's Chat + - -
-

Simple Chat

+ +
+ +
+

Dokkae's Chat

+
-
- @for (message in model.messages.reversed()) - @template.partials.Message(message) - @endfor + +
+
+ @for (message in model.messages) + @template.partials.Message(MessageTemplate(message)) + @endfor +
+ +
+
+ +
+
+ +
+
- - - - + +
+
+
+ +
+ + + +
+
+
-

No auth — anyone can post. Messages are stored only in memory.

+ +
+ \ No newline at end of file diff --git a/src/main/kte/partials/Message.kte b/src/main/kte/partials/Message.kte index 91f406f..4e43280 100644 --- a/src/main/kte/partials/Message.kte +++ b/src/main/kte/partials/Message.kte @@ -1,11 +1,35 @@ -@import at.dokkae.homepage.Message +@import at.dokkae.homepage.templates.MessageTemplate +@import java.time.Instant +@import java.time.ZoneId +@import java.time.format.DateTimeFormatter +@import kotlin.math.absoluteValue -@param message: Message +@param model: MessageTemplate -
- ${message.author}: - ${message.content} - - (${message.createdAt.toString()}) - +!{val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy").withZone(ZoneId.systemDefault())} +!{val timeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault())} +!{val borderColors = listOf("red", "orange", "yellow", "green", "blue", "pink" )} + +
+
+ +
+ +
+ +
+ + ${model.message.author} + + + ${dateFormatter.format(model.message.createdAt)} • ${timeFormatter.format(model.message.createdAt)} + +
+ + +
+ ${model.message.content} +
+
+
\ No newline at end of file diff --git a/src/main/resources/db/migration/V003__fix_updated_at_insert_trigger.sql b/src/main/resources/db/migration/V003__fix_updated_at_insert_trigger.sql new file mode 100644 index 0000000..a731f95 --- /dev/null +++ b/src/main/resources/db/migration/V003__fix_updated_at_insert_trigger.sql @@ -0,0 +1,35 @@ +--- Dropping trigger to prevent them from being called while migrating data +drop trigger if exists handle_message_timestamps on message; + +--- Migrate data +update message +set updated_at = null +where created_at = updated_at + and updated_at is not null; + +--- Replace trigger handler +create or replace function handle_timestamps() + returns trigger as +$$ +begin + if lower(tg_op) = 'insert' then + new.created_at = coalesce(new.created_at, current_timestamp); + new.updated_at = null; + elsif lower(tg_op) = 'update' then + if new.created_at is distinct from old.created_at then + raise exception 'Direct modification of created_at is not allowed.'; + end if; + + new.updated_at = current_timestamp; + end if; + + return new; +end; +$$ language plpgsql; + +--- Reinsert trigger +create trigger handle_message_timestamps + before insert or update + on message + for each row +execute function handle_timestamps(); \ No newline at end of file diff --git a/src/main/resources/static/css/.keep b/src/main/resources/static/css/.keep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/static/css/index.css b/src/main/resources/static/css/index.css new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/static/images/.keep b/src/main/resources/static/images/.keep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/static/js/.keep b/src/main/resources/static/js/.keep new file mode 100644 index 0000000..e69de29