Skip to content

feat: save ln address as contact #85

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 31 commits into
base: main
Choose a base branch
from

Conversation

tiankii
Copy link

@tiankii tiankii commented May 7, 2025

Summary

This pull request refactors the way contacts are handled in the system. Previously, contacts were stored as an embedded array within the Account document. This implementation migrates contact data into a dedicated Contact collection, allowing for more flexible and scalable contact management.

Changes Introduced

  • Created a new Contact Mongoose model and schema
  • Migrated existing embedded contacts from Account.contacts[] into the new Contact collection
  • Refactored the codebase to persist, update, and query contacts through the new Contact collection
  • Updated GraphQL types and resolvers to support contactCreate mutation
  • Added e2e for the contact flow
  • Removed the deprecated contacts field from the Account schema

@tiankii tiankii marked this pull request as ready for review May 14, 2025 22:33
id: string
accountId: string
type: string
identifier: string

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this create a conflict with id... it is better to use handle or just value

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
identifier: string
handle: string

accountId: string
type: string
identifier: string
alias?: string

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alias is more a value that can be used instead of the main handle so... in this case is better to use displayName or just name

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
alias?: string
displayName?: string

identifier: string
alias?: string
transactionsCount: number
createdAt: Date

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if this can be updated then updatedAt is missing

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
createdAt: Date
createdAt: Date
updatedAt?: Date

type: String,
index: true,
unique: true,
sparse: true,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sparse is not necessary since it is a new collection and id is required

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
sparse: true,

type: {
type: String,
enum: Object.values(ContactType),
required: false,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if type is in a unique index it must be required (you have a default value so is not a problem)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
required: false,
required: true,

Comment on lines 76 to 78
export class CouldNotFindContactFromAccountIdError extends CouldNotFindError {}
export class CouldNotFindContactFromContactIdError extends CouldNotFindError {}
export class CouldNotUpdateContactError extends RepositoryError {}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move this to contact domain

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I do that, wouldn't I be breaking the design pattern? Since these are GraphQL errors, and in the contacts/errors.ts file I store application level errors. Could you please confirm that for me?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you need to move them to contacts domain, then add the new domain to core/api/src/app/errors.ts so you can use them at app or servers layers (including graphql)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

file name must match main object/function name

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done! core/api/src/graphql/public/types/payload/contact.ts

import { CouldNotFindContactFromAccountIdError } from "@/domain/errors"
import { ContactsRepository } from "@/services/mongoose"

export const contactCreate = async ({

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

verb at the end is only used at graphql layer

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

path: core/api/src/app/contacts/create-contact.ts

Suggested change
export const contactCreate = async ({
export const createContact = async ({

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this additional file is not necessary, please move it to only one file and move to accounts folders

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, done!


local contact_identifier

if [[ -f "${CACHE_DIR}/${other_token_or_identifier}.username" ]]; then

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why cant you use read_value here?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  local cached_username
  cached_username=$(read_value "$other_token_or_handle.username")

  if [[ -n "$cached_username" ]]; then
    # It's a user token
    contact_handle="$cached_username"
  else
    # It's a raw handle
    contact_handle="$other_token_or_handle"
  fi

@tiankii tiankii requested a review from dolcalmi July 1, 2025 23:14
Copy link

@dolcalmi dolcalmi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good, just minor changes

@@ -80,7 +80,19 @@ const GraphQLUser = GT.Object<User, GraphQLPublicContextAuth>({
type: GT.NonNullList(AccountContact), // TODO: Make it a Connection Interface
description: dedent`Get full list of contacts.
Can include the transactions associated with each contact.`,
resolve: async (source, args, { domainAccount }) => domainAccount?.contacts,
resolve: async (_source, _args, { domainAccount }) => {
if (!domainAccount) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

domain account is not optional, this is not needed

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (!domainAccount) {

accountId: result.accountId as AccountId,
handle: result.handle,
type: result.type as ContactType,
displayName: result.displayName ?? "",
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just as sanity check use ||

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
displayName: result.displayName ?? "",
displayName: result.displayName || "",

Comment on lines 57 to 62
// Clean up embedded contacts from all accounts
const result = await db.collection("accounts").updateMany(
{},
{ $unset: { contacts: "" } }
)
console.log(`Unset contacts field in ${result.modifiedCount} accounts`)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets move this to a new PR (just in case something happen during contacts migration)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

20250702204019-remove-embedded-contacts.ts

// @ts-nocheck
module.exports = {
  async up(db) {
    console.log("Removing embedded contacts from accounts...")

    const result = await db.collection("accounts").updateMany(
      {},
      { $unset: { contacts: "" } }
    )

    console.log(`Unset contacts field in ${result.modifiedCount} accounts`)
  },

  async down() {
    return true
  },
}

Comment on lines +30 to +40
if (type instanceof Error) {
return { errors: [{ message: type.message }] }
}

if (displayName instanceof Error) {
return { errors: [{ message: displayName.message }] }
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handle validation is missing

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (type instanceof Error) {
return { errors: [{ message: type.message }] }
}
if (displayName instanceof Error) {
return { errors: [{ message: displayName.message }] }
}
if (handle instanceof Error) {
return { errors: [{ message: handle.message }] }
}
if (type instanceof Error) {
return { errors: [{ message: type.message }] }
}
if (displayName instanceof Error) {
return { errors: [{ message: displayName.message }] }
}

Comment on lines 76 to 78
export class CouldNotFindContactFromAccountIdError extends CouldNotFindError {}
export class CouldNotFindContactFromContactIdError extends CouldNotFindError {}
export class CouldNotUpdateContactError extends RepositoryError {}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you need to move them to contacts domain, then add the new domain to core/api/src/app/errors.ts so you can use them at app or servers layers (including graphql)

Comment on lines 35 to 37
id: contact.handle as Username,
username: contact.handle as Username,
alias: contact.displayName as ContactAlias,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
id: contact.handle as Username,
username: contact.handle as Username,
alias: contact.displayName as ContactAlias,
id: contact.handle as Username,
username: contact.handle as Username,
alias: contact.displayName,

Comment on lines 21 to 23
id: contact.handle as Username,
username: contact.handle as Username,
alias: contact.displayName as ContactAlias,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
id: contact.handle as Username,
username: contact.handle as Username,
alias: contact.displayName as ContactAlias,
id: contact.handle as Username,
username: contact.handle as Username,
alias: contact.displayName,

Comment on lines 12 to 17
const contacts = await ContactsRepository().listByAccountId(account.id)
if (contacts instanceof Error) return contacts

const contact = contacts.find(
(contact) => contact.handle.toLowerCase() === contactUsername.toLowerCase(),
)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is not necessary, use findByHandle

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const contacts = await ContactsRepository().listByAccountId(account.id)
if (contacts instanceof Error) return contacts
const contact = contacts.find(
(contact) => contact.handle.toLowerCase() === contactUsername.toLowerCase(),
)
const contact = await ContactsRepository().findByHandle({
accountId: account.id,
handle: contactUsername,
})
if (contact instanceof Error) return new NoContactForUsernameError()

@tiankii tiankii requested a review from dolcalmi July 3, 2025 03:17
@esaugomez31
Copy link

@dolcalmi The e2e and integration tests should be run again, because the failures do not seem to be related to the PR

displayName: ContactAlias
incrementTxs?: boolean
}): Promise<Contact | ApplicationError> => {
const contactsRepo = ContactsRepository()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handle is not username where do you use checkedToHandle (app layer needs to have the type validations too

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const contactsRepo = ContactsRepository()
const contactsRepo = ContactsRepository()
const validatedHandle = checkedToHandle(handle)
if (validatedHandle instanceof InvalidHandleError) {
return validatedHandle
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

export const createContact = async ({
  accountId,
  handle,
  displayName,
  type,
}: {
  accountId: AccountId
  handle: string
  type: ContactType
  displayName: ContactAlias
}): Promise<Contact | ApplicationError> => {
  const contactsRepo = ContactsRepository()

  const validatedHandle = checkedToHandle(handle)
  if (validatedHandle instanceof InvalidHandleError) {
    return validatedHandle
  }

  const existing = await contactsRepo.findByHandle({ accountId, handle: validatedHandle })
  if (existing instanceof CouldNotFindContactFromAccountIdError) {
    return contactsRepo.persistNew({
      accountId,
      handle: validatedHandle,
      type,
      displayName,
      transactionsCount: 1,
    })
  }

  if (existing instanceof Error) return existing

  return contactsRepo.update({
    ...existing,
    displayName,
    transactionsCount: existing.transactionsCount + 1,
  })
}

handle,
displayName,
type,
incrementTxs = true,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where do you use this with false?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was there as a flag for the future, in case adding contacts manually without relying on a transaction was allowed later on, but I’ll remove it without any problem.

Suggested change
incrementTxs = true,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didnt ask for removal, validate where it needs to be increase

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that incrementTxs flag is not needed for now, my intention when I added it was to create a generic function where, whenever I ran a mutation to update contacts, I could set it to false since these functions currently only run when making transactions. So for now, it’s not required

}: {
account: Account
contactUsername: Username
}): Promise<AccountContact | ApplicationError> => {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are you sure this type will be handled correctly at graphql layer specially with ln addresses? username from graphql layer can be a problem for this case

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed the AccountContact types so they support LightningAddress via the Handle type.

type AccountContact = {
  readonly id: Handle
  readonly username: Handle
  alias: ContactAlias
  transactionsCount: number
}

Copy link

@dolcalmi dolcalmi Jul 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is not graphql layer, that is why I asked to update the e2e tests to query the contacts (use the same query used in mobile application) and find out if it throws an error with a ln address

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alright, I tested it and added the details in the other comment


return contacts.map((contact) => ({
id: contact.handle,
username: contact.handle,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case, I kept the username value because that’s how the app expects it. I could change it, but that would require GraphQL‑level modifications. Which would be better?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that is what you need to validate with the e2e test, also it is not a problem if you dont create a breaking change in graphql

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, I’ve already validated it. The return now looks like this:

return contacts.map((contact) => ({
  id: contact.handle,
  username: contact.handle,
  handle: contact.handle,
  alias: contact.displayName,
  transactionsCount: contact.transactionsCount,
}))

I keptt username for backward compatibility and marked it as deprecated as well:

import dedent from "dedent"

import ContactAlias from "../scalar/contact-alias"

import Username from "../../../shared/types/scalar/username"
import Handle from "../../../shared/types/scalar/contact-handle"

import { TransactionConnection } from "../../../shared/types/object/transaction"

import { Accounts } from "@/app"
import { checkedToUsername } from "@/domain/accounts"
import { GT } from "@/graphql/index"
import { connectionArgs } from "@/graphql/connections"
import { mapError } from "@/graphql/error-map"

const AccountContact = GT.Object<AccountRecord, GraphQLPublicContextAuth>({
  name: "UserContact",
  fields: () => ({
    id: { type: GT.NonNull(Handle) },
    handle: {
      type: GT.NonNull(Handle),
      description: "Identifier of the contact (username or Lightning address).",
    },
    username: {
      type: GT.NonNull(Username),
      description: "Actual identifier of the contact. Deprecated: use `handle` instead.",
      deprecationReason: "Use `handle` field; this will be removed in a future release.",
      resolve: (src) => src.handle ?? src.username,
    },
    alias: {
      type: ContactAlias,
      description: dedent`Alias the user can set for this contact.
        Only the user can see the alias attached to their contact.`,
    },
    transactionsCount: {
      type: GT.NonNull(GT.Int),
    },
    transactions: {
      type: TransactionConnection,
      args: connectionArgs,
      resolve: async (source, args, { domainAccount }) => {
        if (!source.handle) {
          throw new Error("Missing handle for contact")
        }
        const contactHandle = checkedToUsername(source.handle)

        if (contactHandle instanceof Error) {
          throw mapError(contactHandle)
        }

        const account = domainAccount

        if (account instanceof Error) {
          throw account
        }

        const resp = await Accounts.getAccountTransactionsForContact({
          account,
          contactUsername: contactHandle,
          rawPaginationArgs: args,
        })

        if (resp instanceof Error) {
          throw mapError(resp)
        }

        return resp
      },
      description: "Paginated list of transactions sent to/from this contact.",
    },
  }),
})

export default AccountContact

[[ "$status" == 0 ]] || fail "Contact not found"
}

@test "contact: add lnaddress contact" {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validate query and update

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

Copy link

@dolcalmi dolcalmi Jul 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@test "contact: validate query and update" is not validating the query or please confirm is_contact is using the same query of blink mobile

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’ve reviewed the mobile app and made sure it now uses the same query. The function code is as follows:

is_contact() {
  local owner_token_name="$1"
  local other_token_or_handle="$2"

  local contact_handle

  local cached_username
  cached_username=$(read_value "$other_token_or_handle.username")

  if [[ -n "$cached_username" ]]; then
    # It's a user token
    contact_handle="$cached_username"
  else
    # It's a raw handle
    contact_handle="$other_token_or_handle"
  fi

  [[ -n "$contact_handle" ]] || return 1

  exec_graphql "$owner_token_name" "contacts"
  local match
  match=$(graphql_output ".data.me.contacts[] | select(.username == \"$contact_handle\")")

  [[ -n "$match" ]]
}


import { ContactsRepository } from "@/services/mongoose"

export const getContactByUsername = async ({

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably this should be get contact by handle

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

Suggested change
export const getContactByUsername = async ({
export const getContactByHandle = async ({

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you only need account id then just pass account id (in the previous method contacts were part of account so it made sense)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

Path: core/api/src/app/accounts/get-contacts.ts

export const getContactByHandle = async ({
  accountId,
  handle,
}: {
  accountId: AccountId
  handle: string
}): Promise<AccountContact | ApplicationError> => {
  const validatedHandle = checkedToHandle(handle)
  if (validatedHandle instanceof InvalidHandleError) {
    return validatedHandle
  }

  const contact = await ContactsRepository().findByHandle({
    accountId,
    handle: validatedHandle,
  })
  if (contact instanceof Error) return new NoContactForUsernameError()

  return {
    id: contact.handle,
    username: contact.handle,
    alias: contact.displayName,
    transactionsCount: contact.transactionsCount,
  }
}

core/api/src/graphql/public/types/object/user.ts

const contact = await Accounts.getContactByHandle({
  accountId: domainAccount.id,
  handle: username,
}) 

return value
}

return new InvalidHandleError(value)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

create a proper error and add the return types, this is not a handle

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

Suggested change
return new InvalidHandleError(value)
return new InvalidDisplayNameError(value)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

InvalidHandleError and InvalidContactIdError require a custom error, please use InputValidationError in core/api/src/graphql/error-map.ts

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, done!

core/api/src/graphql/error-map.ts

case "InvalidContactIdError":
  message = "Invalid contact id"
  return new ValidationInternalError({ message, logger: baseLogger })

case "InvalidHandleError":
  message = "Invalid handle"
  return new ValidationInternalError({ message, logger: baseLogger })

case "InvalidDisplayNameError":
  message = "Invalid display name"
  return new ValidationInternalError({ message, logger: baseLogger }) #

@tiankii tiankii force-pushed the feat--save-ln-address-as-contact branch from c6c316b to 84525cd Compare July 17, 2025 17:34
@tiankii tiankii requested a review from dolcalmi July 17, 2025 17:52
@dolcalmi dolcalmi requested a review from esaugomez31 July 23, 2025 01:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants