-
Notifications
You must be signed in to change notification settings - Fork 3
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
base: main
Are you sure you want to change the base?
feat: save ln address as contact #85
Conversation
id: string | ||
accountId: string | ||
type: string | ||
identifier: string |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
identifier: string | |
handle: string |
accountId: string | ||
type: string | ||
identifier: string | ||
alias?: string |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
alias?: string | |
displayName?: string |
identifier: string | ||
alias?: string | ||
transactionsCount: number | ||
createdAt: Date |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
createdAt: Date | |
createdAt: Date | |
updatedAt?: Date |
type: String, | ||
index: true, | ||
unique: true, | ||
sparse: true, |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sparse: true, |
type: { | ||
type: String, | ||
enum: Object.values(ContactType), | ||
required: false, |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
required: false, | |
required: true, |
core/api/src/domain/errors.ts
Outdated
export class CouldNotFindContactFromAccountIdError extends CouldNotFindError {} | ||
export class CouldNotFindContactFromContactIdError extends CouldNotFindError {} | ||
export class CouldNotUpdateContactError extends RepositoryError {} |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done!
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 ({ |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
export const contactCreate = async ({ | |
export const createContact = async ({ |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, done!
bats/helpers/user.bash
Outdated
|
||
local contact_identifier | ||
|
||
if [[ -f "${CACHE_DIR}/${other_token_or_identifier}.username" ]]; then |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this 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) { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if (!domainAccount) { |
accountId: result.accountId as AccountId, | ||
handle: result.handle, | ||
type: result.type as ContactType, | ||
displayName: result.displayName ?? "", |
There was a problem hiding this comment.
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 ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
displayName: result.displayName ?? "", | |
displayName: result.displayName || "", |
// 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`) |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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
},
}
if (type instanceof Error) { | ||
return { errors: [{ message: type.message }] } | ||
} | ||
|
||
if (displayName instanceof Error) { | ||
return { errors: [{ message: displayName.message }] } | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
handle validation is missing
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 }] } | |
} |
core/api/src/domain/errors.ts
Outdated
export class CouldNotFindContactFromAccountIdError extends CouldNotFindError {} | ||
export class CouldNotFindContactFromContactIdError extends CouldNotFindError {} | ||
export class CouldNotUpdateContactError extends RepositoryError {} |
There was a problem hiding this comment.
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)
id: contact.handle as Username, | ||
username: contact.handle as Username, | ||
alias: contact.displayName as ContactAlias, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
same here
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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, |
id: contact.handle as Username, | ||
username: contact.handle as Username, | ||
alias: contact.displayName as ContactAlias, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
same here
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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, |
const contacts = await ContactsRepository().listByAccountId(account.id) | ||
if (contacts instanceof Error) return contacts | ||
|
||
const contact = contacts.find( | ||
(contact) => contact.handle.toLowerCase() === contactUsername.toLowerCase(), | ||
) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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() |
@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() |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
const contactsRepo = ContactsRepository() | |
const contactsRepo = ContactsRepository() | |
const validatedHandle = checkedToHandle(handle) | |
if (validatedHandle instanceof InvalidHandleError) { | |
return validatedHandle | |
} |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
incrementTxs = true, |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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> => { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
}
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
same here
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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" { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
validate query and update
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done!
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 ({ |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done!
export const getContactByUsername = async ({ | |
export const getContactByHandle = async ({ |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done!
return new InvalidHandleError(value) | |
return new InvalidDisplayNameError(value) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 }) #
…contacts collection
…ias to displayName
c6c316b
to
84525cd
Compare
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 dedicatedContact
collection, allowing for more flexible and scalable contact management.Changes Introduced
Contact
Mongoose model and schemaAccount.contacts[]
into the newContact
collectionContact
collectioncontactCreate
mutationcontacts
field from theAccount
schema