Add persistence of reminders and some rudimentary tests

This commit is contained in:
Adrian Gruntkowski 2025-01-18 23:06:55 +01:00
parent e586e10345
commit e388cf5af5
11 changed files with 327 additions and 44 deletions

View file

@ -1,5 +1,9 @@
import Config import Config
config :jenot, Jenot.Repo, database: :memory config :jenot, Jenot.Repo,
database: "priv/db_test.sqlite",
pool: Ecto.Adapters.SQL.Sandbox
config :logger, :default_handler, level: :warning
config :bcrypt_elixir, :log_rounds, 4 config :bcrypt_elixir, :log_rounds, 4

View file

@ -12,6 +12,7 @@ defmodule Jenot.Note do
field(:deleted_at, :utc_datetime_usec) field(:deleted_at, :utc_datetime_usec)
belongs_to(:account, Jenot.Account, type: :binary_id) belongs_to(:account, Jenot.Account, type: :binary_id)
has_one(:reminder, Jenot.Reminder)
field(:server_updated_at, :utc_datetime_usec) field(:server_updated_at, :utc_datetime_usec)
timestamps(type: :utc_datetime_usec) timestamps(type: :utc_datetime_usec)

View file

@ -2,6 +2,7 @@ defmodule Jenot.Notes do
import Ecto.Query import Ecto.Query
alias Jenot.Note alias Jenot.Note
alias Jenot.Reminder
alias Jenot.Repo alias Jenot.Repo
def serialize(note) do def serialize(note) do
@ -13,6 +14,7 @@ defmodule Jenot.Notes do
|> where(account_id: ^account.id) |> where(account_id: ^account.id)
|> select([n], max(n.server_updated_at)) |> select([n], max(n.server_updated_at))
|> Repo.one() |> Repo.one()
|> Repo.preload(:reminder)
end end
def all(account, since \\ nil, include_deleted? \\ false) do def all(account, since \\ nil, include_deleted? \\ false) do
@ -21,15 +23,20 @@ defmodule Jenot.Notes do
|> maybe_include_deleted(include_deleted?) |> maybe_include_deleted(include_deleted?)
|> maybe_filter_since(since) |> maybe_filter_since(since)
|> Repo.all() |> Repo.all()
|> Repo.preload(:reminder)
end end
def add(account, params) do def add(account, params) do
params = deserialize_params(params) params = deserialize_params(params)
changeset = Note.new(account, params) note_changeset = Note.new(account, params)
case upsert(changeset, target: [:account_id, :internal_id]) do with {:ok, note} <- upsert_note(note_changeset, target: [:account_id, :internal_id]),
{:ok, note} -> {:ok, note} reminder_changeset = Reminder.update(note, params["reminder"]),
{:error, _changeset} -> {:error, :invalid_data} {:ok, reminder} <- upsert_reminder(reminder_changeset) do
{:ok, %{note | reminder: reminder}}
else
{:error, _} ->
{:error, :invalid_data}
end end
end end
@ -39,6 +46,7 @@ defmodule Jenot.Notes do
|> where(account_id: ^account.id) |> where(account_id: ^account.id)
|> where(internal_id: ^internal_id) |> where(internal_id: ^internal_id)
|> Repo.one() |> Repo.one()
|> Repo.preload(:reminder)
if note do if note do
{:ok, note} {:ok, note}
@ -51,11 +59,14 @@ defmodule Jenot.Notes do
params = deserialize_params(params) params = deserialize_params(params)
with {:ok, note} <- note_by_internal_id(account, internal_id) do with {:ok, note} <- note_by_internal_id(account, internal_id) do
changeset = Note.update(note, params) note_changeset = Note.update(note, params)
case upsert(changeset, target: [:id]) do with {:ok, note} <- upsert_note(note_changeset, target: [:id]),
{:ok, note} -> {:ok, note} reminder_changeset = Reminder.update(note, params["reminder"]),
{:error, _changeset} -> {:error, :invalid_data} {:ok, reminder} <- upsert_reminder(reminder_changeset) do
{:ok, %{note | reminder: reminder}}
else
{:error, _} -> {:error, :invalid_data}
end end
end end
end end
@ -70,55 +81,83 @@ defmodule Jenot.Notes do
defp deserialize_params(params) do defp deserialize_params(params) do
params = params =
case Map.get(params, "content", Map.get(params, :content)) do case Map.get(params, "content") do
data when is_list(data) -> Map.put(params, "content", Jason.encode!(data)) data when is_list(data) -> Map.put(params, "content", Jason.encode!(data))
_ -> params _ -> params
end end
case Map.get(params, "id", Map.get(params, :id)) do case Map.get(params, "id") do
nil -> nil ->
params params
internal_id -> internal_id ->
params params =
|> Map.delete("id") params
|> Map.delete(:id) |> Map.delete("id")
|> Map.put("internal_id", internal_id) |> Map.put("internal_id", internal_id)
|> deserialize_timestamp(:deleted, :deleted_at) |> deserialize_timestamp("deleted", "deleted_at")
|> deserialize_timestamp(:created, :inserted_at) |> deserialize_timestamp("created", "inserted_at")
|> deserialize_timestamp(:updated, :updated_at) |> deserialize_timestamp("updated", "updated_at")
if reminder = params["reminder"] do
reminder =
reminder
|> Map.put("inserted_at", params["inserted_at"])
|> Map.put("updated_at", params["updated_at"])
Map.put(params, "reminder", reminder)
else
params
end
end end
end end
defp serialize_note(note) do defp serialize_note(note) do
note = note =
note note
|> Map.take([:title, :type, :content, :inserted_at, :updated_at, :deleted_at]) |> Map.take([:title, :type, :content, :inserted_at, :updated_at, :deleted_at, :reminder])
|> Map.put(:id, note.internal_id) |> Map.put(:id, note.internal_id)
|> serialize_timestamp(:deleted_at, :deleted) |> serialize_timestamp(:deleted_at, :deleted)
|> serialize_timestamp(:inserted_at, :created) |> serialize_timestamp(:inserted_at, :created)
|> serialize_timestamp(:updated_at, :updated) |> serialize_timestamp(:updated_at, :updated)
if note.type == :tasklist and not is_nil(note.content) do note =
%{note | content: Jason.decode!(note.content)} if note.type == :tasklist and not is_nil(note.content) do
%{note | content: Jason.decode!(note.content)}
else
note
end
if reminder = note.reminder do
time =
reminder.time
|> Time.to_string()
|> String.split(":")
|> Enum.take(2)
|> Enum.join(":")
reminder =
reminder
|> Map.take([:date, :time, :repeat, :unit, :enabled])
|> Map.put(:date, Date.to_iso8601(reminder.date))
|> Map.put(:time, time)
%{note | reminder: reminder}
else else
note note
end end
end end
defp deserialize_timestamp(params, src_key, dst_key) do defp deserialize_timestamp(params, src_key, dst_key) do
str_src_key = to_string(src_key)
value = value =
case Map.get(params, str_src_key, Map.get(params, src_key)) do case Map.get(params, src_key) do
nil -> nil nil -> nil
unix_time -> DateTime.from_unix!(unix_time, :millisecond) unix_time -> DateTime.from_unix!(unix_time, :millisecond)
end end
params params
|> Map.delete(src_key) |> Map.delete(src_key)
|> Map.delete(str_src_key) |> Map.put(dst_key, value)
|> Map.put(to_string(dst_key), value)
end end
defp serialize_timestamp(data, src_key, dst_key) do defp serialize_timestamp(data, src_key, dst_key) do
@ -150,7 +189,67 @@ defmodule Jenot.Notes do
where(query, [n], n.server_updated_at > ^datetime) where(query, [n], n.server_updated_at > ^datetime)
end end
defp upsert(changeset, opts) do defp upsert_reminder(nil), do: {:ok, nil}
defp upsert_reminder(changeset) do
reminder = Ecto.Changeset.apply_changes(changeset)
conflict_query =
from(r in Reminder,
update: [
set: [
date:
fragment(
"CASE WHEN ? > ? THEN ? ELSE ? END",
^reminder.updated_at,
r.updated_at,
^reminder.date,
r.date
),
time:
fragment(
"CASE WHEN ? > ? THEN ? ELSE ? END",
^reminder.updated_at,
r.updated_at,
^reminder.time,
r.time
),
repeat:
fragment(
"CASE WHEN ? > ? THEN ? ELSE ? END",
^reminder.updated_at,
r.updated_at,
^reminder.repeat,
r.repeat
),
unit:
fragment(
"CASE WHEN ? > ? THEN ? ELSE ? END",
^reminder.updated_at,
r.updated_at,
^reminder.unit,
r.unit
),
enabled:
fragment(
"CASE WHEN ? > ? THEN ? ELSE ? END",
^reminder.updated_at,
r.updated_at,
type(^reminder.enabled, :boolean),
type(r.enabled, :boolean)
)
]
]
)
Repo.insert(changeset,
on_conflict: conflict_query,
conflict_target: [:note_id],
returning: true
)
end
defp upsert_note(changeset, opts) do
conflict_target = Keyword.fetch!(opts, :target) conflict_target = Keyword.fetch!(opts, :target)
type = Ecto.Changeset.get_field(changeset, :type) || :note type = Ecto.Changeset.get_field(changeset, :type) || :note
title = Ecto.Changeset.get_field(changeset, :title) || "" title = Ecto.Changeset.get_field(changeset, :title) || ""

View file

@ -1,16 +1,35 @@
defmodule Jenot.Reminder do defmodule Jenot.Reminder do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset
@primary_key false @primary_key false
schema "reminders" do schema "reminders" do
field(:date, :date) field(:date, :date)
field(:time, :time) field(:time, :time)
field(:repeat, Ecto.Enum, values: [:day, :week, :month, :year]) field(:repeat, :integer)
field(:unit, :integer) field(:unit, Ecto.Enum, values: [:day, :week, :month, :year])
field(:enabled, :boolean) field(:enabled, :boolean)
belongs_to(:note, Jenot.Note) belongs_to(:note, Jenot.Note, type: :binary_id)
timestamps(type: :utc_datetime_usec) timestamps(type: :utc_datetime_usec)
end end
def update(_note, nil), do: nil
def update(note, params) do
%__MODULE__{}
|> cast(params, [
:date,
:time,
:repeat,
:unit,
:enabled,
:inserted_at,
:updated_at
])
|> validate_required([:date, :time, :enabled, :inserted_at, :updated_at])
|> put_assoc(:note, note)
end
end end

15
mix.exs
View file

@ -6,7 +6,9 @@ defmodule Jenot.MixProject do
app: :jenot, app: :jenot,
version: "0.1.0", version: "0.1.0",
elixir: "~> 1.17", elixir: "~> 1.17",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod, start_permanent: Mix.env() == :prod,
aliases: aliases(),
deps: deps(), deps: deps(),
releases: releases() releases: releases()
] ]
@ -19,6 +21,11 @@ defmodule Jenot.MixProject do
] ]
end end
defp elixirc_paths(env) when env in [:test, :dev],
do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
def releases do def releases do
[ [
jenot: [ jenot: [
@ -44,4 +51,12 @@ defmodule Jenot.MixProject do
{:plug_live_reload, "~> 0.1.0", only: :dev} {:plug_live_reload, "~> 0.1.0", only: :dev}
] ]
end end
defp aliases do
[
setup: ["deps.get", "ecto.create", "ecto.migrate"],
"ecto.reset": ["ecto.drop", "ecto.create", "ecto.migrate"],
test: ["ecto.create --quiet", "ecto.migrate", "test"]
]
end
end end

View file

@ -1,19 +1,23 @@
defmodule Jenot.Repo.Migrations.UpdateRemindersSchema do defmodule Jenot.Repo.Migrations.UpdateRemindersSchema do
use Ecto.Migration use Ecto.Migration
def change do def up do
drop table(:reminders) drop_if_exists table(:reminders)
create table(:reminders, primary_key: false) do create table(:reminders, primary_key: false) do
add :date, :date, null: false add :date, :date, null: false
add :time, :time, null: false add :time, :time, null: false
add :repeat_period, :text, null: false add :repeat, :integer, null: true
add :repeat_count, :integer, null: false add :unit, :text, null: true
add :enabled, :boolean, null: false add :enabled, :boolean, null: false
add :note_id, references(:note, on_delete: :delete_all), null: false, primary_key: true add :note_id, references(:notes, on_delete: :delete_all, type: :binary_id), primary_key: true
timestamps(type: :datetime_usec) timestamps(type: :datetime_usec)
end end
end end
def down do
drop_if_exists table(:reminders)
end
end end

View file

@ -172,7 +172,7 @@ export class SyncedNoteStore extends EventTarget {
date: note.reminder.date, date: note.reminder.date,
time: note.reminder.time, time: note.reminder.time,
repeat: note.reminder.count, repeat: note.reminder.count,
unit: note.reminder.unit, unit: note.reminder.unit != "" ? note.reminder.unit : null,
} }
: null, : null,
created: now, created: now,

124
test/jenot/notes_test.exs Normal file
View file

@ -0,0 +1,124 @@
defmodule Jenot.NotesTest do
use Jenot.DataCase
alias Jenot.Notes
setup do
Jenot.Accounts.new()
end
describe "add/2" do
test "adds a new note", %{account: account} do
now = DateTime.utc_now(:millisecond)
timestamp = DateTime.to_unix(now, :millisecond)
assert {:ok, note} =
Notes.add(account, %{
"id" => "id_#{timestamp}",
"type" => "note",
"content" => "Example note body",
"reminder" => nil,
"created" => timestamp,
"updated" => timestamp,
"deleted" => nil
})
assert is_binary(note.id)
assert note.internal_id == "id_#{timestamp}"
assert DateTime.compare(now, note.updated_at) == :eq
end
test "adds a new tasklist note", %{account: account} do
now = DateTime.utc_now(:millisecond)
timestamp = DateTime.to_unix(now, :millisecond)
assert {:ok, note} =
Notes.add(account, %{
"id" => "id_#{timestamp}",
"type" => "tasklist",
"content" => [
%{checked: true, content: "First task"},
%{checked: false, content: "Second task"}
],
"reminder" => nil,
"created" => timestamp,
"updated" => timestamp,
"deleted" => nil
})
assert is_binary(note.id)
assert note.internal_id == "id_#{timestamp}"
assert note.content ==
Jason.encode!([
%{checked: true, content: "First task"},
%{checked: false, content: "Second task"}
])
assert DateTime.compare(now, note.updated_at) == :eq
end
test "adds a new note with a reminder", %{account: account} do
now = DateTime.utc_now(:millisecond)
timestamp = DateTime.to_unix(now, :millisecond)
assert {:ok, note} =
Notes.add(account, %{
"id" => "id_#{timestamp}",
"type" => "note",
"content" => "Example note body",
"reminder" => %{
"enabled" => true,
"date" => "2025-04-01",
"time" => "13:00",
"repeat" => 1,
"unit" => nil
},
"created" => timestamp,
"updated" => timestamp,
"deleted" => nil
})
assert note.internal_id == "id_#{timestamp}"
assert note.reminder.note_id == note.id
assert note.reminder.date == ~D[2025-04-01]
assert note.reminder.time == ~T[13:00:00]
end
end
describe "serialize/1" do
test "serializes note with a reminder", %{account: account} do
now = DateTime.utc_now(:millisecond)
timestamp = DateTime.to_unix(now, :millisecond)
assert {:ok, note} =
Notes.add(account, %{
"id" => "id_#{timestamp}",
"type" => "note",
"content" => "Example note body",
"reminder" => %{
"enabled" => true,
"date" => "2025-04-01",
"time" => "13:00",
"repeat" => 1,
"unit" => nil
},
"created" => timestamp,
"updated" => timestamp,
"deleted" => nil
})
serialized = Notes.serialize(note)
assert serialized.id == note.internal_id
assert serialized.reminder.date == "2025-04-01"
assert serialized.reminder.time == "13:00"
end
end
describe "all/0,1,2" do
test "returns no notes for new account", %{account: account} do
assert [] = Notes.all(account)
end
end
end

View file

@ -1,8 +0,0 @@
defmodule JenotTest do
use ExUnit.Case
doctest Jenot
test "greets the world" do
assert Jenot.hello() == :world
end
end

23
test/support/data_case.ex Normal file
View file

@ -0,0 +1,23 @@
defmodule Jenot.DataCase do
use ExUnit.CaseTemplate
using do
quote do
alias Jenot.Repo
import Ecto
import Ecto.Query
import Jenot.DataCase
end
end
setup tags do
if tags[:async] do
raise "SQLite3 does not support async testing in sandbox mode"
end
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Jenot.Repo, shared: true)
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
:ok
end
end

View file

@ -1 +1,3 @@
ExUnit.start() ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(Jenot.Repo, :manual)