Compare commits

...

15 commits

Author SHA1 Message Date
780efc3e91
feat: add custom email type for validation
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-28 23:37:50 +02:00
2c708d47e6
chore(Justfile): allow regenerating migrations by commit hash 2025-05-28 23:37:47 +02:00
8f14224eb1
choose input filed type by value_type 2025-05-28 23:36:15 +02:00
6cce36b26e
property value as Union type 2025-05-28 23:36:13 +02:00
feb747bd21
chore: add regen_migrations script and seed-database to Justfile 2025-05-28 23:35:19 +02:00
Renovate Bot
0d33f1baf7 chore(deps): update renovate/renovate docker tag to v40
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-22 13:10:48 +02:00
aeb9cb8e29
fix(renovate): Exclude elixir dependencies from postgres update
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-22 13:09:00 +02:00
60f20ceacf
fix: Elixir version matching in renovate config
Some checks reported errors
continuous-integration/drone/push Build was killed
2025-05-22 12:58:45 +02:00
9e7d8b2277
chore: Remove variables from dockerfile base images to enable renovate updates
Some checks reported errors
continuous-integration/drone/push Build was killed
2025-05-22 12:56:11 +02:00
a334ab6bc4
chore(renovate): Use glob patterns to match postgres packages
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-22 12:43:15 +02:00
32fafad196
chore: Also run renovate on push to main branch
Some checks reported errors
continuous-integration/drone/push Build was killed
2025-05-22 12:34:53 +02:00
ddee222f86
chore: Group renovate postgres updates
Some checks reported errors
continuous-integration/drone/push Build was killed
2025-05-22 12:31:31 +02:00
41634711fe
chore: Remove renovate comment about postgresql in asdf
Since postgres is now handled via docker-compose instead of asdf, the
restriction mentioned in the comment does not apply anymore.
2025-05-22 12:31:31 +02:00
cf18d304ee
chore: Ignore elixir dependency in renovate
Some checks reported errors
continuous-integration/drone/push Build was killed
It's a bit complicated to support the `-otp` postfix right now, so to
fix this right now, we'll just disable automatic updates for elixir.
2025-05-22 12:10:18 +02:00
dce8fbc232 Add Release scripts & Dockerfile
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-22 02:12:20 +02:00
18 changed files with 351 additions and 35 deletions

45
.dockerignore Normal file
View file

@ -0,0 +1,45 @@
# This file excludes paths from the Docker build context.
#
# By default, Docker's build context includes all files (and folders) in the
# current directory. Even if a file isn't copied into the container it is still sent to
# the Docker daemon.
#
# There are multiple reasons to exclude files from the build context:
#
# 1. Prevent nested folders from being copied into the container (ex: exclude
# /assets/node_modules when copying /assets)
# 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc)
# 3. Avoid sending files containing sensitive information
#
# More information on using .dockerignore is available here:
# https://docs.docker.com/engine/reference/builder/#dockerignore-file
.dockerignore
# Ignore git, but keep git HEAD and refs to access current commit hash if needed:
#
# $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat
# d0b8727759e1e0e7aa3d41707d12376e373d5ecc
.git
!.git/HEAD
!.git/refs
# Common development/test artifacts
/cover/
/doc/
/test/
/tmp/
.elixir_ls
# Mix artifacts
/_build/
/deps/
*.ez
# Generated on crash by the VM
erl_crash.dump
# Static artifacts - These should be fetched and built inside the Docker image
/assets/node_modules/
/priv/static/assets/
/priv/static/cache_manifest.json

View file

@ -72,13 +72,16 @@ trigger:
event: event:
- cron - cron
- custom - custom
- push
branch:
- main
environment: environment:
LOG_LEVEL: debug LOG_LEVEL: debug
steps: steps:
- name: renovate - name: renovate
image: renovate/renovate:39.264 image: renovate/renovate:40.22
environment: environment:
RENOVATE_CONFIG_FILE: "renovate_backend_config.js" RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
RENOVATE_TOKEN: RENOVATE_TOKEN:

93
Dockerfile Normal file
View file

@ -0,0 +1,93 @@
# Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian
# instead of Alpine to avoid DNS resolution issues in production.
#
# https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu
# https://hub.docker.com/_/ubuntu?tab=tags
#
# This file is based on these images:
#
# - https://hub.docker.com/r/hexpm/elixir/tags - for the build image
# - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20250317-slim - for the release image
# - https://pkgs.org/ - resource for finding needed packages
# - Ex: hexpm/elixir:1.18.3-erlang-27.3-debian-bullseye-20250317-slim
#
ARG BUILDER_IMAGE="hexpm/elixir:1.18.3-erlang-27.3-debian-bullseye-20250317-slim"
ARG RUNNER_IMAGE="debian:bullseye-20250317-slim"
FROM ${BUILDER_IMAGE} as builder
# install build dependencies
RUN apt-get update -y && apt-get install -y build-essential git \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
# prepare build dir
WORKDIR /app
# install hex + rebar
RUN mix local.hex --force && \
mix local.rebar --force
# set build ENV
ENV MIX_ENV="prod"
# install mix dependencies
COPY mix.exs mix.lock ./
RUN mix deps.get --only $MIX_ENV
RUN mkdir config
# copy compile-time config files before we compile dependencies
# to ensure any relevant config change will trigger the dependencies
# to be re-compiled.
COPY config/config.exs config/${MIX_ENV}.exs config/
RUN mix deps.compile
COPY priv priv
COPY lib lib
COPY assets assets
# compile assets
RUN mix assets.deploy
# Compile the release
RUN mix compile
# Changes to config/runtime.exs don't require recompiling the code
COPY config/runtime.exs config/
COPY rel rel
RUN mix release
# start a new build stage so that the final image will only contain
# the compiled release and other runtime necessities
FROM ${RUNNER_IMAGE}
RUN apt-get update -y && \
apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
# Set the locale
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
ENV LANG en_US.UTF-8
ENV LANGUAGE en_US:en
ENV LC_ALL en_US.UTF-8
WORKDIR "/app"
RUN chown nobody /app
# set runner ENV
ENV MIX_ENV="prod"
# Only copy the final release from the build stage
COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/mv ./
USER nobody
# If using an environment that doesn't automatically reap zombie processes, it is
# advised to add an init process such as tini via `apt-get install`
# above and adding an entrypoint. See https://github.com/krallin/tini for details
# ENTRYPOINT ["/tini", "--"]
CMD ["/app/bin/server"]

View file

@ -1,4 +1,4 @@
run: install-dependencies start-database migrate-database run: install-dependencies start-database migrate-database seed-database
mix phx.server mix phx.server
install-dependencies: install-dependencies:
@ -10,6 +10,9 @@ migrate-database:
reset-database: reset-database:
mix ash.reset mix ash.reset
seed-database:
mix run priv/repo/seeds.exs
start-database: start-database:
docker compose up -d docker compose up -d
@ -29,4 +32,42 @@ test:
mix test mix test
format: format:
mix format mix format
build-docker-container:
docker build --tag mitgliederverwaltung .
# This is meant for debugging the container build process only.
run-docker-container: build-docker-container
podman run -e "SECRET_KEY_BASE=ahK8BeiDaibaige1ahkooS0chie9lo7the7uuzar0eeBeeCh2iereteshee2Oosu" -e='DATABASE_URL=postgres://postgres@localhost:5432/mv_dev' -e='PORT=4040' -e='PHX_HOST=localhost' --network=host mitgliederverwaltung
# Usage:
# just regen-migrations migration_name [commit_hash]
# If commit_hash is given, rollback & delete the migrations from that commit.
# Otherwise, rollback & delete all untracked migrations.
regen-migrations migration_name commit_hash='':
#!/usr/bin/env bash
set -euo pipefail
# Pick migrations either from the given commit or untracked files
if [ -n "{{commit_hash}}" ]; then
echo "→ Rolling back migrations from commit {{commit_hash}}"
MIG_FILES=$(git show --name-only --pretty=format: "{{commit_hash}}" \
| grep -E "^priv/repo/migrations/|^priv/resource_snapshots")
else
echo "→ Rolling back all untracked migrations"
MIG_FILES=$(git ls-files --others priv/repo/migrations)
fi
# Roll back in Ash
COUNT=$(echo "$MIG_FILES" | wc -l)
mix ash_postgres.rollback -n "$COUNT"
# Remove the migration files
echo removing $MIG_FILES
echo "$MIG_FILES" | xargs rm -f
# Also clean up any untracked resource snapshots
git ls-files --others priv/resource_snapshots | xargs rm -f
# Generate a fresh migration
mix ash.codegen --name "{{migration_name}}"

View file

@ -48,7 +48,7 @@ if config_env() == :prod do
You can generate one by calling: mix phx.gen.secret You can generate one by calling: mix phx.gen.secret
""" """
host = System.get_env("PHX_HOST") || "example.com" host = System.get_env("PHX_HOST") || raise "Please define the PHX_HOST environment variable."
port = String.to_integer(System.get_env("PORT") || "4000") port = String.to_integer(System.get_env("PORT") || "4000")
config :mv, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") config :mv, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")

34
lib/membership/email.ex Normal file
View file

@ -0,0 +1,34 @@
defmodule Mv.Membership.Email do
@constraints [
match: ~r/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/,
trim?: true,
min_length: 5,
max_length: 254
]
use Ash.Type.NewType,
subtype_of: :string,
constraints: @constraints
@impl true
def cast_input(value, _) when is_binary(value) do
value = if @constraints[:trim?], do: String.trim(value), else: value
cond do
@constraints[:min_length] && String.length(value) < @constraints[:min_length] ->
:error
@constraints[:max_length] && String.length(value) > @constraints[:max_length] ->
:error
@constraints[:match] && !Regex.match?(@constraints[:match], value) ->
:error
true ->
{:ok, value}
end
end
@impl true
def cast_input(_, _), do: :error
end

View file

@ -16,8 +16,17 @@ defmodule Mv.Membership.Property do
attributes do attributes do
uuid_primary_key :id uuid_primary_key :id
attribute :value, :string, attribute :value, :union,
description: "Speichert den Wert, Typ-Interpretation per property_type.typ" constraints: [
storage: :type_and_value,
types: [
boolean: [type: :boolean],
date: [type: :date],
integer: [type: :integer],
string: [type: :string],
email: [type: Mv.Membership.Email]
]
]
end end
relationships do relationships do
@ -25,4 +34,8 @@ defmodule Mv.Membership.Property do
belongs_to :property_type, Mv.Membership.PropertyType belongs_to :property_type, Mv.Membership.PropertyType
end end
calculations do
calculate :value_to_string, :string, expr(value[:value] <> "")
end
end end

View file

@ -10,7 +10,7 @@ defmodule Mv.Membership.PropertyType do
actions do actions do
defaults [:create, :read, :update, :destroy] defaults [:create, :read, :update, :destroy]
default_accept [:name, :type, :description, :immutable, :required] default_accept [:name, :value_type, :description, :immutable, :required]
end end
attributes do attributes do
@ -18,7 +18,8 @@ defmodule Mv.Membership.PropertyType do
attribute :name, :string, allow_nil?: false, public?: true attribute :name, :string, allow_nil?: false, public?: true
attribute :type, :string, attribute :value_type, :atom,
constraints: [one_of: [:string, :integer, :boolean, :date, :email]],
allow_nil?: false, allow_nil?: false,
description: "Definies the datatype `Property.value` is interpreted as" description: "Definies the datatype `Property.value` is interpreted as"

28
lib/mv/release.ex Normal file
View file

@ -0,0 +1,28 @@
defmodule Mv.Release do
@moduledoc """
Used for executing DB release tasks when run in production without Mix
installed.
"""
@app :mv
def migrate do
load_app()
for repo <- repos() do
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
end
end
def rollback(repo, version) do
load_app()
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
end
defp repos do
Application.fetch_env!(@app, :ecto_repos)
end
defp load_app do
Application.load(@app)
end
end

View file

@ -9,7 +9,11 @@ defmodule MvWeb.MemberLive.FormComponent do
Enum.map(property_types, fn pt -> Enum.map(property_types, fn pt ->
%{ %{
"property_type_id" => pt.id, "property_type_id" => pt.id,
"value" => nil "value" => %{
"type" => pt.value_type,
"value" => nil,
"_union_type" => Atom.to_string(pt.value_type)
}
} }
end) end)
@ -34,7 +38,15 @@ defmodule MvWeb.MemberLive.FormComponent do
> >
<.inputs_for :let={f_property} field={@form[:properties]}> <.inputs_for :let={f_property} field={@form[:properties]}>
<% type = Enum.find(@property_types, &(&1.id == f_property[:property_type_id].value)) %> <% type = Enum.find(@property_types, &(&1.id == f_property[:property_type_id].value)) %>
<.input field={f_property[:value]} label={type && type.name} /> <.inputs_for :let={value_form} field={f_property[:value]}>
<% input_type =
cond do
type && type.value_type == :boolean -> "checkbox"
type && type.value_type == :date -> :date
true -> :text
end %>
<.input field={value_form[:value]} label={type && type.name} type={input_type} />
</.inputs_for>
<input <input
type="hidden" type="hidden"
name={f_property[:property_type_id].name} name={f_property[:property_type_id].name}
@ -95,12 +107,27 @@ defmodule MvWeb.MemberLive.FormComponent do
not Enum.member?(existing_properties, Map.get(i, "property_type_id")) not Enum.member?(existing_properties, Map.get(i, "property_type_id"))
end end
params = %{
"properties" =>
Enum.map(member.properties, fn prop ->
%{
"property_type_id" => prop.property_type_id,
"value" => %{
"_union_type" => Atom.to_string(prop.value.type),
"type" => prop.value.type,
"value" => prop.value.value
}
}
end)
}
form = form =
AshPhoenix.Form.for_update( AshPhoenix.Form.for_update(
member, member,
:update_member, :update_member,
api: Mv.Membership, api: Mv.Membership,
as: "member", as: "member",
params: params,
forms: [auto?: true] forms: [auto?: true]
) )

View file

@ -11,7 +11,7 @@ defmodule Mv.Repo.Migrations.InitialMigration do
create table(:property_types, primary_key: false) do create table(:property_types, primary_key: false) do
add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true
add :name, :text, null: false add :name, :text, null: false
add :type, :text, null: false add :value_type, :text, null: false
add :description, :text add :description, :text
add :immutable, :boolean, null: false, default: false add :immutable, :boolean, null: false, default: false
add :required, :boolean, null: false, default: false add :required, :boolean, null: false, default: false
@ -21,7 +21,7 @@ defmodule Mv.Repo.Migrations.InitialMigration do
create table(:properties, primary_key: false) do create table(:properties, primary_key: false) do
add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true
add :value, :text add :value, :map
add :member_id, :uuid add :member_id, :uuid
add :property_type_id, :uuid add :property_type_id, :uuid
end end

View file

@ -2,38 +2,49 @@
# #
# mix run priv/repo/seeds.exs # mix run priv/repo/seeds.exs
# #
# Inside the script, you can read and write to any of your
# repositories directly:
#
# Mv.Repo.insert!(%Mv.SomeSchema{})
#
# We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong.
alias Mv.Membership alias Mv.Membership
for attrs <- [ for attrs <- [
%{ %{
name: "Vorname", name: "Vorname",
type: "string", value_type: :string,
description: "Vorname des Mitglieds", description: "Vorname des Mitglieds",
immutable: true, immutable: true,
required: true required: true
}, },
%{
name: "Nachname",
value_type: :string,
description: "Nachname des Mitglieds",
immutable: true,
required: true
},
%{
name: "Geburtsdatum",
value_type: :date,
description: "Geburtsdatum des Mitglieds",
immutable: true,
required: true
},
%{
name: "Bezahlt",
value_type: :boolean,
description: "Status des Mitgliedsbeitrages des Mitglieds",
immutable: true,
required: true
},
%{ %{
name: "Email", name: "Email",
type: "string", value_type: :email,
description: "Email-Adresse des Mitglieds", description: "Email-Adresse des Mitglieds",
immutable: true, immutable: true,
required: true required: true
} }
] do ] do
# upsert?: true sorgt dafür, dass bei bestehendem Namen kein Fehler, Membership.create_property_type(
# sondern ein Update (hier effektiv No-Op) ausgeführt wird attrs,
{:ok, _} = upsert?: true,
Membership.create_property_type( upsert_identity: :unique_name
attrs, )
upsert?: true,
upsert_identity: :unique_name
)
end end

View file

@ -16,7 +16,7 @@
"custom_indexes": [], "custom_indexes": [],
"custom_statements": [], "custom_statements": [],
"has_create_action": true, "has_create_action": true,
"hash": "A0402269CB456075B81CA4CB3A2135A2C88D8B7FD51CD7A23084AA5264FEE344", "hash": "35D45214D6D344B0AF6CFCB69B8682FCB3D382D85883D3D3AAC1AEE7F54FD89A",
"identities": [], "identities": [],
"multitenancy": { "multitenancy": {
"attribute": null, "attribute": null,

View file

@ -18,7 +18,7 @@
"references": null, "references": null,
"size": null, "size": null,
"source": "value", "source": "value",
"type": "text" "type": "map"
}, },
{ {
"allow_nil?": true, "allow_nil?": true,
@ -84,7 +84,7 @@
"custom_indexes": [], "custom_indexes": [],
"custom_statements": [], "custom_statements": [],
"has_create_action": true, "has_create_action": true,
"hash": "F2A42A3427E0428637F465E4F357A3BE21B33231F94CF77B4843084128F6BDA5", "hash": "8CF241CB9E8239511914EDEC96186BB7879529372BD8A4162431CCE9961F4F1B",
"identities": [], "identities": [],
"multitenancy": { "multitenancy": {
"attribute": null, "attribute": null,

View file

@ -27,7 +27,7 @@
"primary_key?": false, "primary_key?": false,
"references": null, "references": null,
"size": null, "size": null,
"source": "type", "source": "value_type",
"type": "text" "type": "text"
}, },
{ {
@ -66,7 +66,7 @@
"custom_indexes": [], "custom_indexes": [],
"custom_statements": [], "custom_statements": [],
"has_create_action": true, "has_create_action": true,
"hash": "47210108DE1E7B2A20A67205E875B3440526941E61AB95B166976E8CD8AA0955", "hash": "F98A723AE0D20005FBE4205E46ABEE09A88DFF9334C85BADC1FBEEF100F3E25B",
"identities": [ "identities": [
{ {
"all_tenants?": false, "all_tenants?": false,

5
rel/overlays/bin/migrate Executable file
View file

@ -0,0 +1,5 @@
#!/bin/sh
set -eu
cd -P -- "$(dirname -- "$0")"
exec ./mv eval Mv.Release.migrate

5
rel/overlays/bin/server Executable file
View file

@ -0,0 +1,5 @@
#!/bin/sh
set -eu
cd -P -- "$(dirname -- "$0")"
PHX_SERVER=true exec ./mv start

View file

@ -8,8 +8,18 @@
}, },
{ {
"groupName": "asdf tool versions", "groupName": "asdf tool versions",
"description": "Keep in mind that Renovate currently does not support updating PostgreSQL via asdf.",
"matchFileNames": [".tool-versions"] "matchFileNames": [".tool-versions"]
},
{
"groupName": "postgres",
"description": "Group updates to postgres across drone ci, docker-compose.yml and other files",
"matchPackageNames": ["postgres", "docker.io/library/postgres"],
"matchDatasources": ["docker"]
},
{
"matchFileNames": [".tool-versions", "Dockerfile"],
"matchCurrentValue": "**-otp-**",
"enabled": false
} }
] ]
} }