Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,7 @@ config :kith, Oban,
imports: 2,
immich: 3,
purge: 1,
photo_sync: 5,
api_supplement: 3
photo_sync: 5
],
plugins: [
Oban.Plugins.Pruner,
Expand Down
4 changes: 3 additions & 1 deletion lib/kith/accounts/account.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ defmodule Kith.Accounts.Account do
field :timezone, :string, default: "Etc/UTC"
field :locale, :string, default: "en"
field :send_hour, :integer, default: 9
field :phone_format, :string, default: "e164"
field :feature_flags, :map, default: %{}

# Immich integration
Expand Down Expand Up @@ -41,10 +42,11 @@ defmodule Kith.Accounts.Account do
"""
def settings_changeset(account, attrs) do
account
|> cast(attrs, [:name, :timezone, :locale, :send_hour, :feature_flags])
|> cast(attrs, [:name, :timezone, :locale, :send_hour, :phone_format, :feature_flags])
|> validate_required([:name])
|> validate_length(:name, max: 255)
|> validate_number(:send_hour, greater_than_or_equal_to: 0, less_than_or_equal_to: 23)
|> validate_inclusion(:phone_format, ~w(e164 national international raw))
|> validate_timezone()
end

Expand Down
25 changes: 25 additions & 0 deletions lib/kith/contacts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ defmodule Kith.Contacts do
ImmichCandidate,
LifeEventType,
Note,
PhoneFormatter,
Photo,
Relationship,
RelationshipType,
Expand Down Expand Up @@ -387,12 +388,16 @@ defmodule Kith.Contacts do
end

def create_contact_field(%Contact{} = contact, attrs) do
attrs = maybe_normalize_phone(attrs)

%ContactField{contact_id: contact.id, account_id: contact.account_id}
|> ContactField.changeset(attrs)
|> Repo.insert()
end

def update_contact_field(%ContactField{} = field, attrs) do
attrs = maybe_normalize_phone(attrs)

field
|> ContactField.changeset(attrs)
|> Repo.update()
Expand All @@ -402,6 +407,26 @@ defmodule Kith.Contacts do
Repo.delete(field)
end

defp maybe_normalize_phone(attrs) do
cft_id = attrs["contact_field_type_id"] || attrs[:contact_field_type_id]
value = attrs["value"] || attrs[:value]

with cft_id when not is_nil(cft_id) <- cft_id,
%ContactFieldType{protocol: protocol} when protocol in ["tel", "tel:"] <-
Repo.get(ContactFieldType, cft_id),
value when is_binary(value) and value != "" <- value,
{:ok, normalized} when not is_nil(normalized) <-
PhoneFormatter.normalize(value) do
if Map.has_key?(attrs, "value") do
Map.put(attrs, "value", normalized)
else
Map.put(attrs, :value, normalized)
end
else
_ -> attrs
end
end

## Tags

def list_tags(account_id) do
Expand Down
86 changes: 86 additions & 0 deletions lib/kith/contacts/phone_formatter.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
defmodule Kith.Contacts.PhoneFormatter do
@moduledoc """
Phone number normalization and formatting.

Stores numbers in a normalized form internally (E.164 when possible),
formats for display according to account preference.
"""

@doc """
Normalize a phone number for storage.

Strips non-digit characters (preserving leading +), applies best-effort
country code detection for bare numbers.

Returns `{:ok, normalized}` or `{:ok, nil}` for blank input.
"""
def normalize(nil), do: {:ok, nil}
def normalize(""), do: {:ok, nil}

def normalize(phone) when is_binary(phone) do
stripped = String.trim(phone)

has_plus = String.starts_with?(stripped, "+")
digits = String.replace(stripped, ~r/[^\d]/, "")

cond do
digits == "" ->
{:ok, nil}

has_plus ->
{:ok, "+" <> digits}

# US/Canada: bare 10-digit number
String.length(digits) == 10 ->
{:ok, "+1" <> digits}

# US/Canada: 11-digit starting with 1
String.length(digits) == 11 and String.starts_with?(digits, "1") ->
{:ok, "+" <> digits}

# International: 7+ digits, assume needs +
String.length(digits) >= 7 ->
{:ok, "+" <> digits}

# Too short to normalize meaningfully
true ->
{:ok, stripped}
end
end

@doc """
Format a normalized phone number for display.

## Formats

* `"e164"` — E.164 as-is: `+12345678901`
* `"national"` — US/Canada national: `(234) 567-8901`
* `"international"` — International: `+1 234-567-8901`
* `"raw"` — Return as-is, no formatting
"""
def format(nil, _format), do: nil
def format(phone, "raw"), do: phone
def format(phone, "e164"), do: phone
def format(phone, "national"), do: format_national(phone)
def format(phone, "international"), do: format_international(phone)
def format(phone, _), do: phone

# US/Canada: +1 followed by 10 digits
defp format_national(
<<"+"::utf8, ?1, area::binary-size(3), prefix::binary-size(3), line::binary-size(4)>>
)
when byte_size(area) == 3 do
"(#{area}) #{prefix}-#{line}"
end

defp format_national(phone), do: phone

defp format_international(
<<"+"::utf8, ?1, area::binary-size(3), prefix::binary-size(3), line::binary-size(4)>>
)
when byte_size(area) == 3 do
"+1 #{area}-#{prefix}-#{line}"
end

defp format_international(phone), do: phone
end
9 changes: 1 addition & 8 deletions lib/kith/imports.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ defmodule Kith.Imports do
alias Kith.Repo

@sources %{
"monica" => Kith.Imports.Sources.Monica,
"monica_api" => Kith.Imports.Sources.MonicaApi,
"vcard" => Kith.Imports.Sources.VCard
}

Expand Down Expand Up @@ -153,11 +153,4 @@ defmodule Kith.Imports do
|> Ecto.Changeset.change(api_key_encrypted: nil)
|> Repo.update()
end

def pending_async_jobs_count(import_id) do
Oban.Job
|> where([j], fragment("? ->> 'import_id' = ?", j.args, ^to_string(import_id)))
|> where([j], j.state in ["available", "scheduled", "executing", "retryable"])
|> Repo.aggregate(:count)
end
end
2 changes: 1 addition & 1 deletion lib/kith/imports/import.ex
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ defmodule Kith.Imports.Import do
:user_id
])
|> validate_required([:source, :account_id, :user_id])
|> validate_inclusion(:source, ["monica", "vcard"])
|> validate_inclusion(:source, ["monica_api", "vcard"])
|> foreign_key_constraint(:account_id)
|> foreign_key_constraint(:user_id)
|> unique_constraint(:account_id,
Expand Down
14 changes: 2 additions & 12 deletions lib/kith/imports/source.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ defmodule Kith.Imports.Source do
@moduledoc """
Behaviour for import source plugins.

Each source (VCard, Monica, etc.) implements this behaviour to define
Each source (VCard, MonicaApi, etc.) implements this behaviour to define
how to validate, parse, and import data from that source.
"""

Expand All @@ -25,18 +25,8 @@ defmodule Kith.Imports.Source do
@callback supports_api?() :: boolean()

@callback test_connection(credential()) :: :ok | {:error, String.t()}
@callback list_photos(credential(), page :: pos_integer()) ::
{:ok, [map()]} | {:error, term()}
@callback api_supplement_options() :: [
%{key: atom(), label: String.t(), description: String.t()}
]
@callback fetch_supplement(credential(), contact_source_id :: String.t(), key :: atom()) ::
{:ok, map()} | {:error, term()}

@optional_callbacks [
test_connection: 1,
list_photos: 2,
api_supplement_options: 0,
fetch_supplement: 3
test_connection: 1
]
end
Loading
Loading